From a15fe65d5285628883a40f12165ac2d709615052 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 18:39:52 +0400 Subject: [PATCH 01/42] =?UTF-8?q?openspec:=20=D1=81=D0=BF=D0=B5=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=A1=D1=80=D0=B5=D0=B7=D0=B0=203a=20=E2=80=94=20?= =?UTF-8?q?=D0=B3=D0=BE=D1=80=D1=8F=D1=87=D0=B8=D0=B5=20=D0=BF=D0=B0=D0=BF?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BA=D0=B0=D0=BA=20=D1=83=20armka=5Ffile=5Fwor?= =?UTF-8?q?ker,=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=82=D0=BE=D1=80=20OCR=20=E2=86=92=20statementDocument=20v1.2?= =?UTF-8?q?=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit proposal, design (D-1…D-9, решения без обсуждения №1–12), дельты спек image-recognition-gateway (папки, очистка под флагом, нормализация, справочники с редакциями, статусы) и scan-emulator (пара по sha256, набор по умолчанию). tasks.md заполняется планом. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- .../slice-3a-ocr-normalizer/.openspec.yaml | 2 + .../changes/slice-3a-ocr-normalizer/design.md | 151 +++++++++++ .../slice-3a-ocr-normalizer/proposal.md | 62 +++++ .../specs/image-recognition-gateway/spec.md | 243 ++++++++++++++++++ .../specs/scan-emulator/spec.md | 82 ++++++ .../changes/slice-3a-ocr-normalizer/tasks.md | 5 + 6 files changed, 545 insertions(+) create mode 100644 openspec/changes/slice-3a-ocr-normalizer/.openspec.yaml create mode 100644 openspec/changes/slice-3a-ocr-normalizer/design.md create mode 100644 openspec/changes/slice-3a-ocr-normalizer/proposal.md create mode 100644 openspec/changes/slice-3a-ocr-normalizer/specs/image-recognition-gateway/spec.md create mode 100644 openspec/changes/slice-3a-ocr-normalizer/specs/scan-emulator/spec.md create mode 100644 openspec/changes/slice-3a-ocr-normalizer/tasks.md diff --git a/openspec/changes/slice-3a-ocr-normalizer/.openspec.yaml b/openspec/changes/slice-3a-ocr-normalizer/.openspec.yaml new file mode 100644 index 0000000..aa32566 --- /dev/null +++ b/openspec/changes/slice-3a-ocr-normalizer/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-06 diff --git a/openspec/changes/slice-3a-ocr-normalizer/design.md b/openspec/changes/slice-3a-ocr-normalizer/design.md new file mode 100644 index 0000000..88a1b91 --- /dev/null +++ b/openspec/changes/slice-3a-ocr-normalizer/design.md @@ -0,0 +1,151 @@ +# Design: Срез 3a — горячие папки как у armka_file_worker и нормализатор OCR + +## Context + +Срез 0 построил конвейер scan-gateway (ack → S3 → горячая папка → опрос → `raw/` → нормализатор → конверт) с +`NullNormalizer`. Контракт v1.2 (Срез 2b) определил `statementDocument`: `header`, `columns[{column_index, label, +period_date}]`, `lines[{line_code, line_code_confidence, line_name, line_name_confidence, values[{column_index, amount, +string_value, confidence, is_recognized}]}]`, необязательный `checks`. Реальные ответы ИС (`../fixtures/ocr/aep_3.json`, +`aep_6.json`) — `data_ocr {label, organization, inn, units, date, table_structure[{label, rows[{row_index, cells[{column_index, +value, confidence}]}]}]}`; текст в ячейках зашумлён (двойники букв и цифр, `|` как границы клеток, переносы строк, скобки), +в `aep_3.json` у таблицы нет строки-шапки. Старое ядро (`DKB_ARMKA`, `services/ofr_result_conversion/`) решает эти +задачи кодом, проверенным на стенде ИС Сканирование; владелец 06.10.2026 разрешил его перенос (§11.1). + +## Goals / Non-Goals + +Goals: PDF-путь отдаёт ядру тот же `statementDocument` v1.2, что Excel-путь; раскладка горячих папок совпадает с +проверенным в контуре FileWorker; перенос проверенных функций очистки без их DB-обвязки; dev-стенд с эмулятором +работает на реальных парах PDF+JSON. +Non-goals: см. `proposal.md`. + +## Decisions + +### D-1. Имена в горячих папках как у FileWorker, временный файл остаётся в `Tmp` (решение владельца) +`put(job_id, ext, data)` пишет `/...part`, затем rename без замены в `/.`. +Расширение — суффикс `original_name` запроса в нижнем регистре; без суффикса — `RECOGNITION_FAILED` (ИС различает +форматы по расширению). Назначение уже существует (повторная публикация задания после потери ack) → файл считается +доставленным, `.part` остаётся до очистки. Владелец отклонил `.tmp` рядом с назначением (как у FileWorker): ИС может +подхватить недописанный файл; FileWorker полагается на игнор `*.tmp`, у нас такой гарантии нет. `poll` без изменений. + +### D-2. Очистка — отдельный метод под флагом `SCAN_CLEANUP` (решение владельца) +`cleanup(job_id, ext)` удаляет `Output//*.json`, каталог `Output/`, `/.`, +`/..done` (след эмулятора) и `/...part`. Вызывается воркером только после того, +как набор ответа прочитан и склеен в `raw/` (любой исход разбора), и только при `SCAN_CLEANUP=true`. При `TIMEOUT`, +ошибке чтения S3, ошибке записи в горячую папку — не вызывается. Любое удаление, включая `.part`, подчиняется флагу: на +SMB-шаре контура права на удаление не подтверждены. Ошибки удаления — одна строка лога на задание, исход задания и +`/healthcheck` не меняются. По умолчанию `false`; dev-compose — `true`. + +### D-3. Эмулятор ищет пару по sha256 содержимого, при промахе — набор по умолчанию (решение владельца, вариант 3) +Имя входящего файла больше не несёт имени фикстуры. Эмулятор при старте индексирует `SCAN_FIXTURES_DIR`: для каждого +файла, не являющегося `*.json`, считается sha256 → основа имени `` (без расширения); индекс пересчитывается, +если изменился список файлов каталога. Для входящего `.` считается sha256; совпадение → набор `.json` + +`_*.json` по прежним правилам (`.json`, `_.json`, `file_uid = `); промах → +`SCAN_EMULATOR_DEFAULT=` (если задан и его набор существует) → иначе ответ-ошибка `INVALID_DOCUMENT` с текстом «нет +пары по содержимому». Владелец дополнит пары PDF+JSON в fixtures (сейчас пары есть для `aep_3`, `aep_5`, `aep_6`, `aep_8`). + +### D-4. Нормализатор — конвейер чистых функций, проверенные куски переносятся дословно (решение владельца, вариант B) +`src/normalizer/ocr/`: +- `text.py` — `fix_lookalikes` (замена двойников только вплотную к цифре, несколько проходов; `|` как разделитель, если + первая строка состоит из цифр, пробелов, `|`, `•`, `·`, точек, двоеточий, запятых), `clean_value` (буквы, цифры, пробел, + точка; `\n`/`\t` → пробел), `digits_only`, `normalize_header` (нижний регистр, не-буквенно-цифровое → пробел). +- `dates.py` — `parse_date(text) -> date | None`: текстовые формы по приоритету, затем числовые; дословный перенос + `date_utils._parse_date` и паттернов. Паттерны остаются в коде (регулярки с лямбдами). +- `amounts.py` — `parse_amount(text) -> ParsedAmount(amount: Decimal | None, verified: bool)`; `is_blank` (прочерки). +- `layout.py` — `ColumnLayout(label_idx, code_idx, value_idxs)`, `build_layout(cells)` по строке с ячейкой «код…», + `infer_layout(rows_cells, known_codes)` по данным; `cells` — `dict[int, Cell(value, confidence)]`. +- `rows.py` — `parse_table_rows(rows, known_codes) -> list[ParsedRow] | None` (первая строка — шапка настоящая или + синтетическая, повторная шапка перезаписывает раскладку), `classify_row` (`HEADER`/`TOTAL`/`BALANCE`/`ROW`), + `table_type_by_codes`. +- `header.py` — `parse_file_header(data_ocr) -> FileHeader` (организация, ИНН, единицы, дата отчёта, их `confidence` и + признаки «распознано» по §11.1 п. 8). +- `document.py` — `build_document(table, index, file_name, file_header, rules) -> BuiltDocument` (документ контракта + + предупреждения + флаг «всё распознано»). +- `__init__.py` — `OcrNormalizer(rules)`: обход `files` склейки по возрастанию имени, сбор документов, статус (D-6), + `metrics`. Исключение при разборе одной таблицы → предупреждение `BACKEND_PARSING_ERROR`, остальные таблицы + разбираются; исключение вне таблицы → как в Срезе 0 (воркер отвечает `UNKNOWN_ERROR`, `raw/` уже сохранён). +Все функции без I/O и настроек; правила (D-7) передаются объектом. Перенос с именами без подчёркиваний, типами и +docstring'ами на русском; алгоритмы и регулярки — как в источнике, расхождения фиксируются в тестах. + +### D-5. `checks` в документе scan-gateway отсутствуют (решение владельца) +Отсутствие `checks` по контракту означает «не проверялось». Соотношения §7 для обоих источников считает ядро в 3b; +дублировать `statement_checks.yaml` из 2b в scan-gateway не нужно. Чтобы не конфликтовать: ядро не должно полагаться на +`checks` как на единственный источник, это требование уйдёт в change 3b. + +### D-6. Статусы и предупреждения +Файлы ответа: правила Среза 0 без изменений (ошибочные файлы, `error` при всех ошибочных, строки `metrics.warnings`). +Из успешных файлов собираются документы по одному на таблицу: +- ни одной таблицы не разобрано (нигде нет столбца кода, `table_structure` пусты) → `status = error`, + `error.code = RECOGNITION_FAILED`, `error.message = "ни одной таблицы не разобрано"`, причины по таблицам — в warnings; +- есть документы → `done`, если у всех документов `inn_confidence` и `organization_name_confidence` равны 100, + `report_date` и все `period_date` заполнены, все `line_code_confidence`/`line_name_confidence` равны 100, все значения + `is_recognized`, тип совпал с `label`, редакция определена по дате и заполнена, и нет предупреждений уровня таблицы; + иначе `needs_check`. Единицы (`units`) в критерий не входят: у поля нет `confidence` в контракте. +Предупреждения уровня таблицы: `<имя json>: table:: <текст>` — таблица пропущена (нет шапки и столбца кода), шапка +синтезирована (колонки без дат), тип по кодам разошёлся с `label`, колонка без даты, редакция определена по кодам, +редакция `fsbu4` не заполнена, код из другой редакции, `BACKEND_PARSING_ERROR: <класс>: <сообщение>`. Уровня файла: +`<имя json>: ИНН не распознан`, `<имя json>: дата отчёта не разобрана`. Длина и число строк — как в Срезе 0 (`clean_text`). +`metrics.rows` — сумма строк документов. Документ `OTHER` (тип не определён) включается с разобранным содержимым и даёт +`needs_check`. + +### D-7. Правила — данные в `api/rules/`, справочник кодов с редакциями (решение владельца) +- `statement_lines.yaml`: `editions: [{id: 66n, valid_until: 2024-12-31, lines: {...}}, {id: fsbu4, valid_from: + 2025-01-01, lines: {}}]`. `66n` — из `codes_mapping` старого ядра с исправленными опечатками; `fsbu4` — пустая, данные + положит владелец, код не меняется. Выбор редакции для документа: по `report_date` шапки → по самой поздней + `period_date` колонок → по совпадениям кодов (с предупреждением). Пустая выбранная редакция → предупреждение + «редакция fsbu4 не заполнена», имена из OCR, `needs_check`. Код не в выбранной редакции, но есть в другой → + предупреждение, `needs_check`; код нигде → имя из ячейки OCR, `line_code_confidence` из OCR. +- `ocr_lookalikes.yaml`: пары «символ → цифра» и список символов замены. +- `statement_labels.yaml`: нормализованные подстроки `label` таблицы → `BALANCE` / `INCOME`. +Загрузка один раз при старте (`SCAN_RULES_DIR`, по умолчанию `rules/` сервиса), pydantic-валидация; битый yaml — ошибка +старта. Тест покрытия: коды 4–5 цифр, имена непустые, дубли отсутствуют, обе редакции валидируются одинаково. + +### D-8. Тесты +Юнит по каждому модулю `normalizer/ocr/`; golden на реальных фикстурах `../fixtures/ocr/*.json` с эталонами +`../fixtures/ocr/expected_normalize/.json` (формат — файл результата: `status`, `documents`, `warnings`; +структурное сравнение, обновление — `make golden-update`); протокольные тесты нормализатора переводятся на +`OcrNormalizer`; тесты горячих папок, эмулятора, воркера обновляются; `test_roundtrip.py` на `aep_6.pdf` до конверта с +документами. Интеграция не запускается одновременно с интеграцией arm-core (OQ-83). + +### D-9. Настройки и соседние репозитории +`SCAN_CLEANUP=false`, `SCAN_RULES_DIR`, `SCAN_EMULATOR_DEFAULT=""`; `SCAN_TMP_DIR` сохраняется. Ветка — от main после +мержа scan-gateway #5 (копия схемы 1.2). fixtures: пары PDF+JSON от владельца и `expected_normalize/` — отдельный PR. +docs: bounded-PR (`03-architecture.md`, OQ-137, §11.1 п. 6 и 9, новые OQ). + +## Risks / Trade-offs + +- Реальные сканы почти всегда дадут `needs_check` — это ожидаемо; `done` означает лишь отсутствие подсветок в 3b. +- Пустая редакция `fsbu4` до заполнения: документы 2025 года идут с именами из OCR. Цена — больше ручной проверки, не + потеря данных. +- `|`-эвристика и двойники могут исказить редкие корректные значения; `string_value` хранит очищенный, а `raw/` — сырой + текст, аналитик видит оба. +- Rename без замены на SMB: `smbclient.rename` поднимает `FileExistsError`; при гонке с ИС, уже забравшей файл, + назначения может не быть — тогда `.part` переименовывается повторно, это идемпотентно. + +## Migration Plan + +Не требуется: сервис без БД; изменение имён файлов видят только ИС и эмулятор; контракт не меняется. Dev-стенд: после +мержа обновить `docs/dev/.env.example` и compose (`SCAN_CLEANUP=true`, `SCAN_EMULATOR_DEFAULT` по желанию). + +## Open Questions + +1. Права на удаление на SMB-шаре контура (новый OQ) — пока `SCAN_CLEANUP=false`. +2. Нужна ли ядру редакция формы в конверте (кандидат в MINOR 1.3) — новый OQ. +3. Словари §7 и §12 по редакциям (2b, 3b) — новый OQ, расширение OQ-12. +4. Состав редакции `fsbu4` — данные владельца. + +## Decisions made without discussion + +| № | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| 1 | 2026-10-06 | Один `statementDocument` на каждую таблицу `table_structure[i]`; `locator = "file:<имя json>;table:"` | §11: тип документа задаёт `label` таблицы; `locator` нужен ядру для привязки к `raw/` | смена формата `locator` | +| 2 | 2026-10-06 | Расширение входящего файла — суффикс `original_name` в нижнем регистре; без суффикса `RECOGNITION_FAILED` | ИС различает форматы по расширению; FileWorker делает так же | потеря заданий без расширения — их не бывает (ядро проверяет сигнатуру) | +| 3 | 2026-10-06 | Очистка не вызывается при `TIMEOUT` и транспортных ошибках | ИС может ещё писать ответ; FileWorker чистит только после результата | мусор в папке при таймаутах | +| 4 | 2026-10-06 | Исключение при разборе одной таблицы — предупреждение, остальные таблицы разбираются; исключение вне таблиц — `UNKNOWN_ERROR` воркера | §11.1 п. 9, поведение старого ядра по файлам | скрытая ошибка в предупреждении вместо конверта `error` | +| 5 | 2026-10-06 | Документ без строки-шапки отдаётся с синтетической шапкой и колонками без `period_date`, `needs_check` | рулинг §11.1 п. 3 | аналитик расставляет даты руками | +| 6 | 2026-10-06 | Выбор редакции: `report_date` → поздняя `period_date` → совпадения кодов | дата отчёта надёжнее кодов; коды — последний резерв | неверная редакция при мусорной дате; тогда предупреждение по кодам | +| 7 | 2026-10-06 | Паттерны дат остаются в коде, двойники и `label` — в yaml | регулярки с лямбдами в yaml нечитаемы; таблицы символов — данные | править даты — через код | +| 8 | 2026-10-06 | `OTHER` включается в `documents`, а не отбрасывается | как `OTHER` у Excel-пути 2b; ядро решает | лишний документ на экране проверки | +| 9 | 2026-10-06 | Эмулятор пересчитывает индекс хешей при изменении списка файлов каталога, а не на каждый файл | дёшево и достаточно для dev | изменённый, но не переименованный PDF не переиндексируется | +| 10 | 2026-10-06 | `label` колонки — сырой текст шапки с `\n` → пробел; `period_date` из `parse_date` | ядро показывает текст как есть | — | +| 12 | 2026-10-06 | `parse_amount` переносится дословно: скобка, распознанная как `}` (`(7744536}`), минус не даёт — сумма положительная с `is_recognized = false` | проверенный код; расширение множества скобок — отдельное правило §11.1 после проверки на стенде | знак потерян, аналитик видит `raw/` и подсветку | +| 11 | 2026-10-06 | Имя строки: из словаря редакции по коду; из OCR — только если кода нет в словаре | старое ядро, §11.1 п. 6 | расхождение имени словаря и формы клиента не видно аналитику (видно в `raw/`) | diff --git a/openspec/changes/slice-3a-ocr-normalizer/proposal.md b/openspec/changes/slice-3a-ocr-normalizer/proposal.md new file mode 100644 index 0000000..3aed2e8 --- /dev/null +++ b/openspec/changes/slice-3a-ocr-normalizer/proposal.md @@ -0,0 +1,62 @@ +# Proposal: Срез 3a — горячие папки как у armka_file_worker и нормализатор OCR → `statementDocument` v1.2 + +**Срез:** 3a, первая половина Среза 3 «ББ/ОФР: scan-gateway, маппинг форм, логические проверки» (`../docs/context/09-delivery-plan.md`). +**Репозиторий:** scan-gateway. **Issue:** RSHB/arm-scan-gateway#6. +**Соседние:** fixtures — пары PDF+JSON и `ocr/expected_normalize/` (issue заводится при плане); docs — bounded-PR без change +(`03-architecture.md`, OQ-137, §11.1 п. 6 и п. 9, новые OQ). Срез 3b (маппинг `statementDocument` в ядре, проверки §7, +подтверждение, чек-лист) — отдельный change в arm-core. + +## Why + +Срез 0 оставил scan-gateway с `NullNormalizer`: любой успешный ответ ИС Сканирование превращается в `NOT_IMPLEMENTED`. +Срез 2b даёт ядру `statementDocument` v1.2 из Excel; чтобы ядро в 3b маппило формы одним кодом для обоих источников, +PDF-путь должен отдавать тот же документ. Второй повод — 06.10.2026 в контуре проверен PDFWorker старого АРМ +(`RSHB/armka_file_worker`): его раскладка горячих папок ИС принята владельцем как эталон (OQ-137), а модуль очистки OCR +(`ofr_result_conversion`) разрешено переносить (§11.1 `05-domain-rules.md`, единственное исключение для `reference-old-arm`). + +## What Changes + +- **Горячие папки** (`src/gateway/`): входящий файл — `/.` (расширение исходного имени в нижнем + регистре) вместо `_`; временный `.part` по-прежнему в `` (решение владельца: в `Input` + временных файлов не класть, ИС может их подхватить), rename **без замены** — существующее назначение считается + доставленным. Новый метод `cleanup(job_id, ext)` под флагом `SCAN_CLEANUP` (по умолчанию `false`): удаляет + `Output//*.json`, каталог, входящий файл (и его `.done`-вариант эмулятора) и осиротевший `.part`; любое + удаление только при включённом флаге (на SMB может не быть прав). Ошибка удаления не меняет исход задания. +- **Эмулятор** (`src/scan_emulator/`): входящий `.`; пара фикстур ищется по **sha256 содержимого** среди + не-JSON файлов `SCAN_FIXTURES_DIR` (`aep_6.pdf` → `aep_6.json` + `aep_6_*.json`); промах → набор + `SCAN_EMULATOR_DEFAULT`, если задан, иначе ответ-ошибка `INVALID_DOCUMENT`. `.part`/`.tmp`/`.done` игнорируются. +- **Нормализатор** (`src/normalizer/ocr/`): `OcrNormalizer` реализует протокол `Normalizer` Среза 0 — конвейер чистых + функций `text` → `dates` → `amounts` → `layout` → `rows` → `header` → `document`; проверенные на стенде функции + старого ядра (двойники, `|`, разбор дат, суммы, раскладка с шапкой и без) переносятся дословно по §11.1; выход — + pydantic-модели контракта v1.2 (`StatementDocument`), по одному документу на таблицу `table_structure[i]`. + `checks` не считаются (их считает ядро в 3b для обоих источников). +- **Статусы**: ошибочные файлы — как в Срезе 0; из успешных собираются документы; ни одной таблицы → + `error RECOGNITION_FAILED`; всё распознано → `done`, иначе `needs_check`; причины — в `metrics.warnings`. +- **Правила как данные** (`api/rules/`): `statement_lines.yaml` с **редакциями** форм (`66n` до отчётности за 2024 + включительно — из `codes_mapping` старого ядра; `fsbu4` с 2025 — пустая, данные положит владелец), `ocr_lookalikes.yaml`, + `statement_labels.yaml`. Загружаются и валидируются при старте. +- **Настройки**: `SCAN_CLEANUP`, `SCAN_RULES_DIR`, `SCAN_EMULATOR_DEFAULT`; `SCAN_TMP_DIR` остаётся. Dev-compose: + `SCAN_CLEANUP=true`. +- **Тесты**: юнит по функциям, golden на `../fixtures/ocr/aep_3.json`, `aep_6.json` (эталоны `../fixtures/ocr/expected_normalize/`), + обновлённые тесты горячих папок, эмулятора, воркера; roundtrip с эмулятором. + +## Non-goals + +- Маппинг `statementDocument` в `financial_statement_*`, проверки §7, подтверждение документа, чек-лист, UI — Срез 3b. +- Изменение контракта (v1.2 заморожен): редакция формы в конверт не добавляется (OQ, кандидат в MINOR 1.3). +- Расшифровки без кода (OQ-36), склейка одной формы из нескольких таблиц (OQ-77), `DISCLOSURE` из OCR. +- Заполнение словаря редакции `fsbu4` и подбор пар PDF+JSON — владелец, отдельный PR в fixtures. +- Перенос обвязки старого ядра (`json_to_db_rows`, запись в БД, `pdfworker_results.py`). + +## Параграфы `05-domain-rules.md` + +§11 (структура ответа ИС, маппинг таблица → документ), §11.1 п. 1–9 (очистка OCR-текста), §12 п. 4 (разбор дат — +общий смысл с Excel-путём). Правила, которых в `05` нет и которые вводятся этим change'ом: редакции форм в справочнике +кодов (ASSUMPTION(OQ-12): состав 2025 года не задан), `is_recognized` колонок шапки. Таблицы DBML не затрагиваются +(сервис без БД). + +## Открытые вопросы, затрагиваемые change'ом + +OQ-137 (уточнить: `Tmp` остаётся, очистка под флагом), OQ-36 (строки без кода не отдаются), OQ-77 (одна форма двумя +таблицами — два документа с предупреждением), OQ-12 (редакции форм и §7), новые: права на удаление на SMB-шаре; +редакция формы в контракте; §7/§12 по редакциям для 2b и 3b. diff --git a/openspec/changes/slice-3a-ocr-normalizer/specs/image-recognition-gateway/spec.md b/openspec/changes/slice-3a-ocr-normalizer/specs/image-recognition-gateway/spec.md new file mode 100644 index 0000000..ad5f4ee --- /dev/null +++ b/openspec/changes/slice-3a-ocr-normalizer/specs/image-recognition-gateway/spec.md @@ -0,0 +1,243 @@ +# Spec Delta: image-recognition-gateway + +## Purpose + +Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: принимает и подтверждает задание из NATS без JetStream, +передаёт файл через горячие папки по раскладке проверенного в контуре PDFWorker (`RSHB/armka_file_worker`), ждёт ответ +не дольше таймаута, сохраняет сырой ответ в S3, нормализует `data_ocr` в `statementDocument` контракта v1.2 по §11 и +§11.1 `05-domain-rules.md` и отвечает ядру. Без БД и фронта. + +## MODIFIED Requirements + +### Requirement: Подтверждение запроса до обработки +Сервис SHALL быть подписан на `recognition.image.request` в queue group `scan-gateway`. Получив запрос, проходящий схему, +сервис SHALL до чтения файла из S3 опубликовать в `recognition.result` конверт `status = processing` +(`service.name = "scan-gateway"`, `Nats-Msg-Id = ack::`). Повторный запрос с `job_id`, который уже +обрабатывается или обработан этим процессом, SHALL получить повторный ack, и второй файл во входящий каталог MUST NOT записываться. + +#### Scenario: Ack раньше файла +- **WHEN** получен запрос для `aep_5.pdf` +- **THEN** конверт `processing` опубликован раньше, чем файл появился во входящем каталоге + +#### Scenario: Повторный запрос +- **WHEN** тот же запрос доставлен повторно во время ожидания ответа ИС +- **THEN** публикуется ещё один ack, во входящем каталоге остаётся один файл `.pdf` + +### Requirement: Передача файла в ИС Сканирование через горячие папки +После подтверждения сервис SHALL прочитать исходник из S3 (`s3.bucket/s3.key`) и записать его во входящий каталог как +`.`, где `` — расширение `original_name` запроса в нижнем регистре; запрос без расширения SHALL +завершаться конвертом `RECOGNITION_FAILED` без записи в горячую папку. Временный файл записи SHALL лежать в служебном +каталоге `<корень>//` (по умолчанию `Tmp`, на той же шаре/ФС, что `Input`; создаётся при записи) и +переименовываться в `Input` атомарно **без замены**: если `/.` уже существует, файл SHALL считаться +доставленным, временный файл MUST NOT его перезаписывать. Во входящем каталоге MUST NOT появляться недописанные и +временные файлы. Затем сервис SHALL опрашивать исходящий каталог `//` каждые +`SCAN_POLL_SECONDS` до `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`. ИС кладёт в этот каталог все распознанные +документы задания — по одному `*.json` на документ, набор появляется целиком и больше не меняется; пустой или +отсутствующий каталог означает ожидание. Ответ SHALL считаться готовым, когда в каталоге есть хотя бы один `*.json`, а +набор имён и размеров `*.json` не менялся `SCAN_SETTLE_SECONDS` (по умолчанию 2); готовый набор SHALL включать **все** +`*.json` каталога по возрастанию имени, остальные файлы игнорируются. + +#### Scenario: Файл положен во входящий каталог +- **WHEN** получен запрос для `aep_5.PDF` с `job_id = 3f1c…` +- **THEN** во входящем каталоге появляется `3f1c….pdf`, байт в байт равный объекту S3; во время записи во входящем каталоге нет временных файлов, а `Tmp/` существует + +#### Scenario: Назначение уже существует +- **WHEN** запрос для `aep_5.pdf` с `job_id = 3f1c…` обрабатывается, а `Input/3f1c….pdf` уже лежит (задание повторено ядром после потери ack в другом процессе) +- **THEN** существующий файл не перезаписан, задание продолжается опросом `Output/3f1c…/`, конверт `RECOGNITION_FAILED` не публикуется + +#### Scenario: Запрос без расширения +- **WHEN** получен запрос с `original_name = "scan"` без точки +- **THEN** опубликован конверт `error`, `error.code = RECOGNITION_FAILED`, сообщение о расширении; во входящем каталоге файла нет + +#### Scenario: Ответ из одного файла +- **WHEN** в `Output/3f1c…/` появляется один `*.json` и набор не меняется `SCAN_SETTLE_SECONDS` +- **THEN** опрос прекращается не позднее чем через `SCAN_SETTLE_SECONDS` + `SCAN_POLL_SECONDS` после первого опроса, увидевшего окончательный набор (отсчёт стабилизации — от этого опроса, а не от времени изменения файла), ответ из одного файла передаётся дальше по конвейеру + +#### Scenario: Ответ из нескольких файлов +- **WHEN** в `Output/3f1c…/` лежат `3f1c….json` и `3f1c…_ofr.json`, посторонний файл `readme.txt`, и набор не меняется `SCAN_SETTLE_SECONDS` +- **THEN** дальше по конвейеру передаются оба `*.json` по возрастанию имени, `readme.txt` игнорируется + +#### Scenario: Набор ещё пишется +- **WHEN** размер файла или число файлов в `Output/3f1c…/` менялось менее `SCAN_SETTLE_SECONDS` назад +- **THEN** ответ не считается готовым, опрос продолжается до дедлайна + +### Requirement: Результат по контракту +Сервис SHALL публиковать в `recognition.result` конверт по `recognition-result-message.schema.json` с +`service.name = "scan-gateway"`, `Nats-Msg-Id = result::`; при `status ∈ {done, needs_check}` — файл +результата (`recognition-result.schema.json`, `schema_version = "1.2"`) в `results/{file_id}/{job_id}/result.json` и `result_s3`; +при неудаче публикации SHALL повторить её до трёх раз. Нормализатор получает склеенный ответ и SHALL считать файл +успешным, если `error.code == ""` (поле `error` реального успешного ответа — `{"code": "", "message": ""}`, а не `null`; +отсутствующий или `null` `error` ошибкой не считается), и ошибочным, если `error.code` непуст, `parse_error` не `null` +либо корень ответа файла не является объектом JSON (ошибка этого файла — `INVALID_FORMAT`); успех MUST NOT определяться +проверкой «`error` не `null`». Если ошибочны **все** файлы — `status = error`, `error.code` и `error.message` берутся у +первого ошибочного файла по имени (для `parse_error` и не-объекта — `INVALID_FORMAT`); если код ИС этого файла не +входит в перечень кодов контракта, `error.code = UNKNOWN_ERROR`, а исходный код SHALL быть указан в `error.message`. +Если есть хотя бы один успешный файл — документы собираются из успешных файлов по требованию «Нормализация ответа ИС», +а статус определяется по требованию «Статус результата нормализации». В обоих случаях `metrics.warnings` SHALL +содержать по одной строке `<имя файла>: <код>: <сообщение>` на каждый ошибочный файл, где `<код>` — исходный код ИС как +получен (для `parse_error` и не-объекта — `INVALID_FORMAT`); если предупреждений нет, поле `warnings` SHALL быть опущено. + +#### Scenario: Успешный ответ ИС в Срезе 0 +- **WHEN** ответ из одного файла с `error = {"code": "", "message": ""}` и непустым `data_ocr` (по образцу `aep_6.json`) +- **THEN** начиная со Среза 3a конверт больше не `NOT_IMPLEMENTED`: поведение — по сценарию «Успешный ответ ИС» ниже + +#### Scenario: Успешный ответ ИС +- **WHEN** ответ из одного файла с `error = {"code": "", "message": ""}` и непустым `data_ocr` (`aep_6.json`) +- **THEN** конверт `status = needs_check`, `raw_s3_key = raw/.json`, `result_s3` указывает на файл результата с двумя документами (`BALANCE`, `INCOME`) + +#### Scenario: Ошибка ИС +- **WHEN** единственный файл ответа `.json` содержит `error.code = INVALID_DOCUMENT` +- **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT`, `error.message` из ответа, `raw_s3_key` заполнен, `metrics.warnings` содержит одну строку `.json: INVALID_DOCUMENT: <сообщение>` + +#### Scenario: Ошибка одного из документов +- **WHEN** ответ состоит из `.json` (успех, `aep_3.json`) и `_ofr.json` (`INVALID_DOCUMENT`, `aep_3_ofr.json`) +- **THEN** конверт `status = needs_check`, документы собраны из `.json`, `metrics.warnings` содержит строку `_ofr.json: INVALID_DOCUMENT: <сообщение>` и предупреждения нормализации + +#### Scenario: Все документы ошибочны +- **WHEN** оба файла ответа ошибочны (первый по имени — `INVALID_DOCUMENT`, второй — не JSON) +- **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT` (код первого ошибочного файла по имени), `metrics.warnings` содержит две строки; файл не JSON в warnings несёт код `INVALID_FORMAT` + +#### Scenario: Все файлы не JSON +- **WHEN** единственный файл ответа не разбирается как JSON +- **THEN** конверт `status = error`, `error.code = INVALID_FORMAT`, `metrics.warnings` содержит одну строку `.json: INVALID_FORMAT: <сообщение разбора>` + +#### Scenario: Корень ответа не объект +- **WHEN** файл ответа — корректный JSON, но корень — массив +- **THEN** этот файл ошибочен с кодом `INVALID_FORMAT`; остальные файлы оцениваются по общим правилам + +#### Scenario: Код ошибки ИС вне перечня контракта +- **WHEN** единственный файл ответа содержит `error.code = SOME_NEW_CODE` +- **THEN** конверт `status = error`, `error.code = UNKNOWN_ERROR`, `error.message` содержит `SOME_NEW_CODE`, строка в `metrics.warnings` — `.json: SOME_NEW_CODE: <сообщение>` + +### Requirement: Ошибки транспорта превращаются в результат +Сервис SHALL отвечать конвертом `status = error` с кодом по таблице: запрос не по схеме или `MAJOR` ≠ 1 → +`SCHEMA_MISMATCH` (ack не публикуется); объект S3 не читается → `FILE_READ_ERROR`; запись во входящий каталог не +удалась или у исходника нет расширения → `RECOGNITION_FAILED`; файл ответа ИС не разбирается как JSON → `parse_error` +этого файла, а если ошибочны все файлы — `INVALID_FORMAT`; ни одной таблицы не разобрано нормализатором → +`RECOGNITION_FAILED`; иное исключение → `UNKNOWN_ERROR`. Ошибки очистки горячей папки MUST NOT влиять на конверт. +Во всех случаях конверт SHALL быть опубликован, процесс MUST NOT завершаться. + +#### Scenario: Горячая папка недоступна +- **WHEN** каталог `SCAN_HOTFOLDER_PATH` не существует в момент обработки +- **THEN** конверт `RECOGNITION_FAILED` с сообщением о недоступности горячей папки; `/healthcheck.hotfolder = down` + +## ADDED Requirements + +### Requirement: Очистка горячей папки под флагом +При `SCAN_CLEANUP=true` после того, как готовый набор ответа прочитан и склеен в `raw/{job_id}.json` (независимо от +исхода нормализации), сервис SHALL удалить `//*.json`, сам каталог ``, входящий файл +`/.`, его вариант `/..done` и временный `/...part`, если они +существуют. При `SCAN_CLEANUP=false` (по умолчанию) сервис MUST NOT удалять ничего, включая временные файлы. При +`TIMEOUT`, `FILE_READ_ERROR`, `RECOGNITION_FAILED` записи очистка MUST NOT выполняться. Ошибка удаления SHALL быть +записана в лог одной строкой на задание и MUST NOT менять конверт, `metrics.warnings` и `/healthcheck`. + +#### Scenario: Очистка включена +- **WHEN** `SCAN_CLEANUP=true`, ответ на `3f1c….pdf` прочитан из `Output/3f1c…/` +- **THEN** после публикации конверта в горячей папке нет `Output/3f1c…/`, `Input/3f1c….pdf`, `Input/3f1c….pdf.done` и `Tmp/.3f1c….pdf.part` + +#### Scenario: Очистка выключена +- **WHEN** `SCAN_CLEANUP=false`, ответ прочитан, а в `Tmp/` остался осиротевший `.part` +- **THEN** все файлы остаются на месте, в логе одна строка с путём `.part` + +#### Scenario: Нет прав на удаление +- **WHEN** `SCAN_CLEANUP=true`, удаление `Output/3f1c…/3f1c….json` завершается `PermissionError` +- **THEN** конверт опубликован с тем же статусом, что и без очистки; в логе одна строка об ошибке удаления; `/healthcheck.hotfolder = ok` + +#### Scenario: Таймаут не чистит +- **WHEN** `SCAN_CLEANUP=true`, ответ не появился до дедлайна +- **THEN** опубликован `TIMEOUT`, входящий файл и каталог `Output//` (если есть) не тронуты + +### Requirement: Нормализация ответа ИС в `statementDocument` +Для каждого успешного файла ответа нормализатор SHALL разобрать `data_ocr.table_structure[i]` в отдельный +`statementDocument` по §11 и §11.1 `05-domain-rules.md`: +- `document_type` — по нормализованному `label.form_name` таблицы через `statement_labels.yaml`; если `label` не + сопоставлен — по первой цифре кодов строк данных (`1` → `BALANCE`, `2` → `INCOME`); иначе `OTHER`. Если `label` и коды + дают разный тип — тип по `label`, предупреждение; `locator = "file:<имя json>;table:"`. +- Раскладка колонок — по строке с ячейкой «код…» (`build_layout`); повторная шапка перезаписывает раскладку; без шапки — + `infer_layout` по данным (столбец с наибольшим числом кодов из справочника, наименование — левее, значения — правее, + где суммы не реже мусора или столбец пуст), шапка синтезируется, предупреждение. Ни шапки, ни столбца кода — таблица + пропускается с предупреждением. +- `columns` — колонки значений раскладки по возрастанию `column_index` исходной таблицы, перенумерованные с 0; + `label` — текст ячейки шапки (`\n`/`\t` → пробел; у синтетической шапки — пустая строка), `period_date` — `parse_date` + текста (квартал/полугодие/N месяцев/диапазон → последний день последнего месяца), `null`, если не разобрался. +- `lines` — только строки данных с кодом: `line_code` — очищенный текст ячейки кода (`clean_value`); строка без кода и + строка с нечисловым кодом (повторный заголовок) пропускаются. `line_name` — из справочника редакции по коду; если кода + нет в справочниках — текст ячейки наименования. `line_code_confidence` — `confidence` ячейки кода, если очищенный текст + равен сырому и код есть в справочнике, иначе `min(confidence, 99)`; `line_name_confidence` — то же для словарного + имени, иначе `confidence` ячейки наименования. +- `values` — по одному на колонку: `amount` — `parse_amount` (запятая → точка, скобки → минус, пробелы и прочие символы + отбрасываются, несколько точек — разделители тысяч; прочерк/пусто → `null`); `string_value` — `clean_value` сырого + текста (`null`, если пусто); `confidence` — ячейки; `is_recognized = confidence == 100 и разбор не отбрасывал символов`; + ячейки под колонкой нет → `amount = null`, `string_value = null`, `confidence = 0`, `is_recognized = false`. +- `header` — `form_name` из `label` таблицы; `organization_name` и `organization_name_confidence` из `data_ocr.organization` + (`\n` → пробел); `inn` — только цифры из `data_ocr.inn.form_name` (`null`, если цифр нет), `inn_confidence = confidence`, + если сырой текст состоял только из цифр, иначе `min(confidence, 99)`; `units` — `clean_value(units.form_name)`; + `report_date_text` — сырой текст `date.form_name`, `report_date` — `parse_date` после `fix_lookalikes` + (`null`, если текст не дата); `okud`, `okpo`, `okopf`, `okfs`, `address` — `null` (ИС их не отдаёт). +- `checks` MUST NOT присутствовать. +Двойники букв и цифр (`fix_lookalikes`) SHALL применяться к тексту кодов, сумм, дат и ИНН только вплотную к цифрам; +`|` SHALL считаться разделителем клеток, если первая строка значения состоит из цифр, пробелов и знаков пунктуации. + +#### Scenario: Чистый скан с шапкой (`aep_6.json`) +- **WHEN** нормализуется `aep_6.json` (две таблицы: «Бухгалтерский баланс» 122 строки со строкой-шапкой «Наименование показателя / Код / На 30 июня 2025 г / На 31 декабря 2024 г. / На 31 декабря 2023 г.» и «Отчет о финансовых результатах» 49 строк) +- **THEN** два документа: `BALANCE` с `columns` `[{0, "На 30 июня 2025 г", 2025-06-30}, {1, "На 31 декабря 2024 г.", 2024-12-31}, {2, "На 31 декабря 2023 г.", 2023-12-31}]`, строка `1105` и `1110` присутствуют, строка «в том числе: …» без кода отсутствует; `INCOME` с колонками «За январь - июнь 2025 г.» → `2025-06-30` и «За январь - июнь 2024 г.» → `2024-06-30`, строка `2110` с `values[0].amount = 13070`; `header.units = "тыс. руб."`, `header.inn = null` (текст `+`), `header.report_date = null` (текст — название организации); статус `needs_check` + +#### Scenario: Зашумлённый скан без шапки (`aep_3.json`) +- **WHEN** нормализуется `aep_3.json` (таблица «Бухгалтерский баланс» без строки-шапки, коды в `column_index = 1` с мусором вида `изо :`, ИНН `3|1 21 2| о! О' 0 0; 3: 51`, дата `2|0|2|4|`) +- **THEN** столбец кода выведен по данным, документ `BALANCE` с синтетической шапкой, `columns` без `period_date` и предупреждение «шапка синтезирована»; `header.inn = "3121200351"`, `inn_confidence < 100`; `header.report_date = 2024-12-31`; строка `1120` присутствует; статус `needs_check` + +#### Scenario: Суммы в скобках и с пробелами (`aep_3.json`, ОФР) +- **WHEN** ячейка значения содержит `(7744536}` с `confidence 88.9`, другая — `(6032409)` с `confidence 100`, третья — `13 070` с `confidence 100` +- **THEN** `amount` соответственно `7744536` (закрывающая скобка не распознана, минус не выставлен, `is_recognized = false`), `-6032409` (`is_recognized = true`), `13070` (`is_recognized = true`); `string_value` — очищенный текст (`7744536`, `6032409`, `13 070`) + +#### Scenario: Тип по кодам при зашумлённом label +- **WHEN** `label.form_name` таблицы — `Бvxгалтерский баланс` (не сопоставлен), коды строк `1110…1700` +- **THEN** `document_type = BALANCE`, предупреждение «тип определён по кодам» + +#### Scenario: Таблица без столбца кода +- **WHEN** в таблице нет строки с ячейкой «Код» и ни в одном столбце нет кодов из справочника +- **THEN** таблица пропущена, предупреждение `<имя json>: table:: нет шапки и столбца кода`; остальные таблицы файла разобраны + +### Requirement: Справочники нормализации — данные с редакциями форм +Правила SHALL лежать в `api/rules/` (`SCAN_RULES_DIR`) и загружаться один раз при старте с pydantic-валидацией; битый +файл SHALL прерывать старт сервиса. `statement_lines.yaml` SHALL содержать список `editions` с полями `id`, +`valid_from`/`valid_until` (даты отчёта) и `lines` (код → наименование); редакция `66n` (до 2024-12-31) заполнена, +`fsbu4` (с 2025-01-01) присутствует и может быть пустой. Редакция документа SHALL выбираться по `header.report_date`, +при его отсутствии — по самой поздней `period_date` колонок, при отсутствии обеих — по числу совпадений кодов с +предупреждением «редакция определена по кодам». Пустая выбранная редакция → предупреждение «редакция не +заполнена», имена строк — из OCR. Код, отсутствующий в выбранной редакции, но присутствующий в другой → предупреждение +«код <код> из редакции ». `ocr_lookalikes.yaml` SHALL задавать таблицу двойников; `statement_labels.yaml` — +нормализованные подстроки `label` → `BALANCE`/`INCOME`. Коды справочника SHALL быть строками из 4–5 цифр без дублей. + +#### Scenario: Редакция по дате отчёта +- **WHEN** `header.report_date = 2024-12-31`, строка с кодом `1150` +- **THEN** `line_name = "Основные средства"` из редакции `66n`, предупреждений о редакции нет + +#### Scenario: Редакция 2025 не заполнена +- **WHEN** `report_date = null`, самая поздняя `period_date = 2025-06-30`, редакция `fsbu4` пуста +- **THEN** имена строк взяты из ячеек OCR, предупреждение `редакция fsbu4 не заполнена`, статус `needs_check` + +#### Scenario: Битый справочник +- **WHEN** в `statement_lines.yaml` код `11A0` +- **THEN** сервис не стартует, в логе ошибка валидации с именем файла и кодом + +### Requirement: Статус результата нормализации +Если из успешных файлов не собран ни один документ — `status = error`, `error.code = RECOGNITION_FAILED`, +`error.message = "ни одной таблицы не разобрано"`, причины по таблицам — в `metrics.warnings`. Если документы есть — +`status = done` только когда у всех документов `inn_confidence`, `organization_name_confidence` равны 100, +`report_date` и все `period_date` заполнены, все `line_code_confidence` и `line_name_confidence` равны 100, все значения +`is_recognized`, тип совпал с `label`, редакция определена по дате и заполнена, и нет предупреждений уровня таблицы; +иначе `status = needs_check`. Предупреждения уровня таблицы SHALL иметь вид `<имя json>: table:: <текст>`, уровня +файла — `<имя json>: <текст>`; `metrics.rows` — сумма строк всех документов. + +#### Scenario: Ни одной таблицы +- **WHEN** единственный успешный файл содержит `table_structure: []` +- **THEN** конверт `status = error`, `error.code = RECOGNITION_FAILED`, `raw_s3_key` заполнен + +#### Scenario: Всё распознано +- **WHEN** синтетический ответ, где все `confidence = 100`, тексты чистые, шапка есть, даты колонок и отчёта разобраны, `report_date = 2024-12-31`, коды из редакции `66n` +- **THEN** `status = done`, поле `metrics.warnings` отсутствует + +#### Scenario: Исключение в одной таблице +- **WHEN** разбор `table_structure[1]` бросает исключение, `table_structure[0]` разбирается +- **THEN** один документ, предупреждение `<имя json>: table:1: BACKEND_PARSING_ERROR: <класс>: <сообщение>`, статус `needs_check` diff --git a/openspec/changes/slice-3a-ocr-normalizer/specs/scan-emulator/spec.md b/openspec/changes/slice-3a-ocr-normalizer/specs/scan-emulator/spec.md new file mode 100644 index 0000000..49fe895 --- /dev/null +++ b/openspec/changes/slice-3a-ocr-normalizer/specs/scan-emulator/spec.md @@ -0,0 +1,82 @@ +# Spec Delta: scan-emulator + +## Purpose + +Эмулятор ИС Сканирование для разработки и тестов: воспроизводит протокол горячих папок (раскладка FileWorker: +`.` → `Output//*.json`) и формат ответов Content AI, отдавая реальные ответы из `../fixtures/ocr` по +содержимому входящего файла, чтобы конвейер `scan-gateway` проверялся без доступа к банковской системе. + +## RENAMED Requirements +- FROM: `### Requirement: Ответы берутся из фикстур по имени` +- TO: `### Requirement: Ответы берутся из фикстур по содержимому входящего файла` + +## MODIFIED Requirements + +### Requirement: Эмулятор отвечает по протоколу горячих папок +Процесс `python -m src.scan_emulator` SHALL опрашивать входящий каталог каждую секунду; файлы с суффиксами `.part`, +`.tmp`, `.done` и файлы, начинающиеся с `.`, SHALL игнорироваться. Для нового файла `.` через +`SCAN_EMULATOR_DELAY` секунд (по умолчанию 2) эмулятор SHALL создать каталог `//` с набором ответов (по +одному файлу на документ); каталог SHALL появиться в `` атомарно целиком: набор записывается во временный +каталог эмулятора и переименовывается. После обработки входящий файл SHALL быть переименован в `..done`, +чтобы не обрабатываться повторно. + +#### Scenario: Один файл +- **WHEN** во входящем каталоге появляется `3f1c….pdf` с содержимым `aep_5.pdf` +- **THEN** через ≤ 5 с существует `Output/3f1c…/3f1c….json`, а входящий файл переименован в `3f1c….pdf.done` + +#### Scenario: Набор появляется целиком +- **WHEN** эмулятор записывает набор из нескольких файлов +- **THEN** каталог `Output/3f1c…/` либо не существует, либо уже содержит весь набор; частичного набора в `Output` нет + +#### Scenario: Временные файлы шлюза игнорируются +- **WHEN** во входящем каталоге лежат `.3f1c….pdf.part` и `3f1c….pdf.done` +- **THEN** эмулятор не создаёт для них ответов + +### Requirement: Ответы берутся из фикстур по содержимому входящего файла +Эмулятор SHALL индексировать `SCAN_FIXTURES_DIR`: для каждого файла, не являющегося `*.json`, вычисляется sha256 → +основа имени `` (имя без расширения; регистр расширения не важен); индекс SHALL пересчитываться, когда меняется +список файлов каталога. Для входящего файла SHALL вычисляться sha256 содержимого; при совпадении эмулятор SHALL собрать +**все** файлы `.json` и `_*.json`: `.json` записывается как `.json`, `_.json` — как +`_.json`, в каждом `file_uid = `. Фикстуры вида `<другие символы>.json` без разделителя `_` +(например, `aep_30.json` для `aep_3`) MUST NOT попадать в набор. При промахе по хешу и заданном +`SCAN_EMULATOR_DEFAULT=` с существующим набором эмулятор SHALL отдать набор ``; лог SHALL сообщать, что +пара найдена по умолчанию. + +#### Scenario: Точное имя +- **WHEN** входящий файл называется `.pdf`, а в фикстурах есть `aep_5.pdf` и `aep_5.json` +- **THEN** имя входящего файла не используется для поиска: пара определяется только по содержимому (сценарий «Пара по содержимому») + +#### Scenario: Пара по содержимому +- **WHEN** входящий файл байт в байт равен `aep_5.pdf`, в фикстурах есть `aep_5.json` +- **THEN** в `Output//` один файл `.json`, равный `aep_5.json` с заменённым `file_uid` + +#### Scenario: Суффикс +- **WHEN** входящий файл равен `aep_8.pdf`, в фикстурах есть `aep_8_form4.json` +- **THEN** в `Output//` файл `_form4.json`, равный `aep_8_form4.json` с заменённым `file_uid` + +#### Scenario: Несколько файлов на одно задание +- **WHEN** входящий файл равен `aep_3.pdf`, в фикстурах есть `aep_3.json` и `aep_3_ofr.json` +- **THEN** в `Output//` два файла: `.json` и `_ofr.json`, в обоих `file_uid = ` + +#### Scenario: Чужой префикс +- **WHEN** входящий файл равен `aep_3.pdf`, в фикстурах есть также `aep_30.json` +- **THEN** `aep_30.json` в набор не попадает + +#### Scenario: Промах с набором по умолчанию +- **WHEN** `SCAN_EMULATOR_DEFAULT=aep_6`, входящий файл — произвольный PDF, которого нет в фикстурах +- **THEN** в `Output//` набор `aep_6` (`.json` из `aep_6.json`), в логе отметка «по умолчанию» + +#### Scenario: Имя входящего файла не влияет +- **WHEN** входящий файл называется `.pdf`, а его содержимое равно `aep_6.pdf` +- **THEN** отдан набор `aep_6` + +### Requirement: Неизвестный файл — ошибка в формате Content AI +Если пара по содержимому не найдена и `SCAN_EMULATOR_DEFAULT` не задан (или его набор отсутствует), эмулятор SHALL +записать один ответ `.json` в формате реальных ответов-ошибок ИС (`aep_5.json`, `aep_8_form4.json`): +`file_uid = `, `error = {"code": "INVALID_DOCUMENT", "message": ": нет пары по содержимому в +фикстурах"}`, `data_ocr` — **объект** с пустыми полями (`label`, `organization`, `inn`, `units`, `date` — `{"form_name": +"", "confidence": 0}`) и `table_structure: []`. + +#### Scenario: Нет пары +- **WHEN** входящий файл — произвольный PDF, `SCAN_EMULATOR_DEFAULT` не задан +- **THEN** ответ содержит `error.code = INVALID_DOCUMENT`, `data_ocr` — объект с пустыми полями и `table_structure: []`; `scan-gateway` публикует конверт с тем же кодом diff --git a/openspec/changes/slice-3a-ocr-normalizer/tasks.md b/openspec/changes/slice-3a-ocr-normalizer/tasks.md new file mode 100644 index 0000000..741ef31 --- /dev/null +++ b/openspec/changes/slice-3a-ocr-normalizer/tasks.md @@ -0,0 +1,5 @@ +# Tasks: Срез 3a — горячие папки и нормализатор OCR + +Заполняется планом (`writing-plans`) после одобрения спеки владельцем. Репозиторий — scan-gateway; команды — из `api/`. + +- [ ] 0.1 План реализации написан и одобрен; задачи перенесены сюда из плана -- 2.47.2 From 0894d2b89bb16720b1b25ea349898bd627304f76 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 18:59:07 +0400 Subject: [PATCH 02/42] =?UTF-8?q?openspec:=20=D1=80=D0=B5=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=E2=84=9612=20(=D1=81=D0=BA=D0=BE=D0=B1?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BA=D0=B0=D0=BA=20})=20=E2=80=94=20=D0=B2=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D1=82=D1=8B=D0=B5=20=D0=B2=D0=BE?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D1=8B,=20follow-up=20=D0=B2=20docs=20O?= =?UTF-8?q?Q=20(=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=B5=D0=BB=D1=8C=D1=86=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- openspec/changes/slice-3a-ocr-normalizer/design.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openspec/changes/slice-3a-ocr-normalizer/design.md b/openspec/changes/slice-3a-ocr-normalizer/design.md index 88a1b91..bd526e9 100644 --- a/openspec/changes/slice-3a-ocr-normalizer/design.md +++ b/openspec/changes/slice-3a-ocr-normalizer/design.md @@ -132,6 +132,9 @@ docs: bounded-PR (`03-architecture.md`, OQ-137, §11.1 п. 6 и 9, новые OQ 2. Нужна ли ядру редакция формы в конверте (кандидат в MINOR 1.3) — новый OQ. 3. Словари §7 и §12 по редакциям (2b, 3b) — новый OQ, расширение OQ-12. 4. Состав редакции `fsbu4` — данные владельца. +5. Скобки, распознанные как `}` / `]` (`(7744536}` в `aep_3.json`): дословный `parse_amount` минус не ставит (решение №12). + Владелец 06.10.2026: «пока устроит как есть», вынести в follow-up — новый OQ в `07-open-questions.md` (расширить + множество закрывающих скобок в §11.1 п. 7 после проверки на стенде). ## Decisions made without discussion -- 2.47.2 From ec916aee3fbfa9e9cb1890ae7f4e25797f2b259c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 19:19:24 +0400 Subject: [PATCH 03/42] =?UTF-8?q?plan:=20=D0=A1=D1=80=D0=B5=D0=B7=203a=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=80=D0=B5=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20(18=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87),=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=E2=84=9613=20=D0=B2=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- .../2026-10-06-slice-3a-ocr-normalizer.md | 3390 +++++++++++++++++ .../changes/slice-3a-ocr-normalizer/design.md | 1 + 2 files changed, 3391 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-06-slice-3a-ocr-normalizer.md diff --git a/docs/superpowers/plans/2026-10-06-slice-3a-ocr-normalizer.md b/docs/superpowers/plans/2026-10-06-slice-3a-ocr-normalizer.md new file mode 100644 index 0000000..1ac840b --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-slice-3a-ocr-normalizer.md @@ -0,0 +1,3390 @@ +# Срез 3a — горячие папки как у armka_file_worker и нормализатор OCR: план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** scan-gateway отдаёт ядру `statementDocument` v1.2 из ответов ИС Сканирование (вместо `NOT_IMPLEMENTED`), +а горячие папки и эмулятор работают по раскладке проверенного в контуре PDFWorker (`Input/.`, +`Output//*.json`, очистка под флагом). + +**Architecture:** `OcrNormalizer` реализует существующий протокол `Normalizer` конвейером чистых функций +`src/normalizer/ocr/{text,dates,amounts,layout,rows,header,document}.py`; проверенные на стенде функции старого ядра +(`../reference-old-arm/api/src/core/PDFWorker/utils.py`, `date_utils.py`) переносятся дословно (единственное +разрешённое исключение для `reference-old-arm`), DB-обвязка не переносится. Справочники — yaml в `api/rules/` с +редакциями форм. Горячие папки: `put(job_id, ext, data)` + rename без замены + `cleanup` под `SCAN_CLEANUP`; +эмулятор ищет пару фикстур по sha256. + +**Tech Stack:** Python 3.11, pydantic v2, PyYAML (уже в requirements), pytest (`asyncio_mode=auto`), ruff, mypy; +без новых зависимостей. + +**Spec:** `openspec/changes/slice-3a-ocr-normalizer/` (`proposal.md`, `design.md` D-1…D-9, `specs/image-recognition-gateway/spec.md`, +`specs/scan-emulator/spec.md`). Правила предметной области — `../docs/context/05-domain-rules.md` §11, §11.1. + +## Global Constraints + +- Репозиторий scan-gateway, ветка `feat/6-slice-3a-ocr-normalizer`, worktree `.wt/slice-3a/scan-gateway`; команды `make` — из корня + репозитория, `pytest` — из `api/` (`cd api && .venv/bin/pytest …`). Перед первой задачей — `make venv`. +- Ветка должна содержать контракт v1.2: Task 0 вливает `origin/chore/contract-v1-2` (scan-gateway PR #5). Контракт + **не меняется**: `api/contracts/*.schema.json` остаются байт в байт равными `../docs/contracts` (тест `test_contract.py`). + Если `../docs/contracts` ещё v1.0 (docs #27 не слит), тест идентичности упадёт — это ожидаемо до мержа docs #27; задача 17 проверяет. +- Язык комментариев, docstring'ов, сообщений и коммитов — русский; идентификаторы — английские. Строка ≤ 120 символов + (`ruff`, правила `E, F, I, UP, B, PL`); `mypy src` с `check_untyped_defs`. `make check` зелёный после каждой задачи. +- Функции `src/normalizer/ocr/*` — чистые: без I/O, без `get_settings()`, без логирования внутри конвейера (кроме + `OcrNormalizer.normalize`, который возвращает предупреждения строками). Деньги — `Decimal`, никаких `float` в суммах. +- Перенос из `../reference-old-arm/api/src/core/PDFWorker/` разрешён **только** для функций очистки (`utils.py`: + `_keep_digits_only`, `_clear_value`, `_remove_spec_space`, `_parse_amount`, `_normalize_header`, `_is_code_header`, + `_is_value_header`, `_build_layout`, `_classify_row`, `_is_blank`, `_infer_layout`, `_parse_table_rows`, + `_synthetic_header_cells`, `_resolve_table_type`, регулярки `_YEAR_RE`, `_BLANK_CHARS_RE`, `NUM_RE`, `ALLOWED_RE`, + `BRACKET_RE`; `date_utils.py` целиком). Алгоритмы и регулярки — как в источнике; меняются только имена, типы, + docstring'и и передача справочников параметром. `json_to_db_rows`, `_process_file`, dataclass'ы `Meta/DbRow/DbValue`, + `consts.py` как код — **не переносятся**. +- `reference-old-arm` только для чтения: никаких записей, `git pull` и т. п. в нём (хук блокирует). +- Никаких `cd` в основные checkout'ы workspace (`../docs`, `../arm-core` на `main`): параллельно работает контроллер + Среза 2b; читать их файлы — только абсолютными путями. +- Коммиты после каждой задачи; сообщения вида `feat(normalizer): …`, `feat(hotfolder): …`, `test(...)`, `docs(...)`; + в конце — строки атрибуции из системного напоминания сессии. + +## Review Focus + +1. **Шапка-повтор внутри таблицы с другим числом колонок** (`aep_6` ОФР имеет 2 колонки, баланс — 3): после повторной + шапки раскладка перезаписывается, и значения строк после неё должны идти по новым индексам, а `columns` документа + — по первой шапке. Тест в Task 6 (`test_second_header_overrides_layout`). +2. **Строка данных, у которой ячейка кода есть, а ячеек значений нет вовсе** (`cells` короче раскладки): `values` + обязаны содержать по элементу на каждую колонку с `is_recognized = false`, иначе ядро сдвинет колонки. Тест в Task 8 + (`test_missing_cells_keep_column_count`). +3. **`report_date` после `fix_lookalikes` даёт дату из названия организации** (`aep_6`: `date.form_name` = «Общество с + ограниченной ответственностью»): `parse_date` обязан вернуть `None`, а не год из случайных цифр. Тест в Task 3 + (`test_organization_name_is_not_a_date`). +4. **Повторная публикация задания, когда `Input/.` уже есть, а `Output//` уже удалён очисткой** + прошлого прогона: `put` считает файл доставленным, опрос ждёт до дедлайна → `TIMEOUT`, повторной отправки нет. Тест + в Task 13 (`test_existing_input_waits_for_answer`). +5. **Эмулятор получает файл, который ещё дописывается шлюзом** (`.part` в `Tmp`, а не в `Input`) и файл `.done`: оба + игнорируются, индекс хешей не пересчитывается на каждый проход. Тест в Task 14 (`test_ignores_part_tmp_done`). + +--- + +## Структура файлов + +| Путь (от `api/`) | Ответственность | +|---|---| +| `rules/statement_lines.yaml` | справочник код → наименование по редакциям `66n`, `fsbu4` | +| `rules/ocr_lookalikes.yaml` | таблица двойников букв и цифр | +| `rules/statement_labels.yaml` | подстроки `label` таблицы → `BALANCE`/`INCOME` | +| `src/normalizer/rules.py` | pydantic-модели правил, `load_rules(path) -> OcrRules`, выбор редакции | +| `src/normalizer/ocr/text.py` | `fix_lookalikes`, `clean_value`, `digits_only`, `normalize_header`, `collapse_whitespace` | +| `src/normalizer/ocr/dates.py` | `parse_date` (дословный перенос `date_utils`) | +| `src/normalizer/ocr/amounts.py` | `parse_amount`, `is_blank` | +| `src/normalizer/ocr/layout.py` | `Cell`, `ColumnLayout`, `cells_by_index`, `build_layout`, `infer_layout` | +| `src/normalizer/ocr/rows.py` | `RowKind`, `ParsedRow`, `classify_row`, `parse_table_rows`, `table_type_by_codes` | +| `src/normalizer/ocr/header.py` | `FileHeader`, `parse_file_header` | +| `src/normalizer/ocr/document.py` | `BuiltDocument`, `build_document` | +| `src/normalizer/ocr/__init__.py` | `OcrNormalizer` (статус, предупреждения, `metrics`) | +| `src/normalizer/__init__.py` | экспорт `OcrNormalizer`, общие помощники ошибок файлов (остаются) | +| `src/gateway/hotfolder.py` | `inbox_name(job_id, original_name) -> str` (`.`), `put`, `cleanup`, rename без замены | +| `src/gateway/smb.py` | то же для SMB | +| `src/core/worker.py` | очистка после склейки под флагом, `RECOGNITION_FAILED` без расширения | +| `src/core/config.py` | `SCAN_CLEANUP`, `SCAN_RULES_DIR` | +| `src/app.py` | `OcrNormalizer(load_rules(...))` в lifespan | +| `src/scan_emulator/__init__.py` | индекс sha256, `SCAN_EMULATOR_DEFAULT`, `.` | +| `tests/unit/normalizer/test_*.py` | юнит по модулям конвейера, golden | +| `tests/unit/test_*hotfolder.py`, `test_emulator.py`, `test_worker.py`, `test_normalizer.py` | обновление | +| `tests/integration/test_roundtrip.py` | сквозной сценарий с документами | + +--- + +### Task 0: Контракт v1.2 в ветке и окружение + +**Files:** +- Modify: ветка (merge), `api/.venv` (создание) + +**Interfaces:** +- Produces: модели `src.contract.result`: `StatementDocument` с `document_type ∈ {BALANCE, INCOME, DISCLOSURE, OTHER}`, + `checks: StatementChecks | None`; `StatementValue.string_value: str | None`; `StatementChecks`, `FailedRelation`. + +- [ ] **Step 1: Влить копию схемы v1.2** + +```bash +cd /Users/andrey.epifancev/projects/awp-ca/.wt/slice-3a/scan-gateway +git fetch origin +git merge --no-edit origin/chore/contract-v1-2 +``` +Ожидание: merge без конфликтов (ветка #5 меняет только `api/contracts/`, `api/src/contract/result.py`, `tests/unit/test_contract.py`, README). + +- [ ] **Step 2: Окружение и базовая проверка** + +```bash +make venv +make check +``` +Ожидание: `ruff`/`mypy` чисты; pytest зелёный, кроме, возможно, `tests/unit/test_contract.py::test_schemas_identical_to_docs`, +если `../docs/contracts` ещё v1.0 (docs #27 не слит) — тогда зафиксировать это в отчёте задачи и продолжать. + +- [ ] **Step 3: Проверить, что модель видит v1.2** + +```bash +cd api && .venv/bin/python -c "from src.contract.result import StatementValue, StatementChecks; print(StatementValue.model_fields['string_value'])" +``` +Ожидание: печать описания поля без ошибки импорта. + +- [ ] **Step 4: Commit** — merge-коммит уже создан шагом 1; дополнительного коммита нет. + +--- + +### Task 1: Настройки и справочники (`rules/*.yaml`, `src/normalizer/rules.py`) + +**Files:** +- Create: `api/rules/statement_lines.yaml`, `api/rules/ocr_lookalikes.yaml`, `api/rules/statement_labels.yaml` +- Create: `api/src/normalizer/rules.py` +- Modify: `api/src/core/config.py` (после `SCAN_MAX_INFLIGHT`) +- Test: `api/tests/unit/normalizer/__init__.py` (пустой), `api/tests/unit/normalizer/test_rules.py` + +**Interfaces:** +- Produces: + - `Settings.SCAN_CLEANUP: bool = False`, `Settings.SCAN_RULES_DIR: str = "rules"`. + - `class Edition(BaseModel)`: `id: str`, `valid_from: date | None`, `valid_until: date | None`, `lines: dict[str, str]`. + - `class OcrRules(BaseModel)`: `editions: list[Edition]`, `lookalikes: dict[str, str]`, `labels: dict[str, Literal["BALANCE","INCOME"]]`; + методы `edition_for(report_date: date | None) -> Edition | None` (по датам), `edition_by_codes(codes: Iterable[str]) -> Edition | None` + (максимум совпадений, при равенстве — первая), `known_codes() -> frozenset[str]` (объединение всех редакций), + `editions_with(code: str) -> list[str]` (id редакций, где код есть). + - `load_rules(directory: Path | str) -> OcrRules` — читает три yaml, `ValueError` с именем файла при нарушении. + +- [ ] **Step 1: Написать падающие тесты** + +`api/tests/unit/normalizer/__init__.py` — пустой файл. `api/tests/unit/normalizer/test_rules.py`: + +```python +"""Справочники нормализатора (spec «Справочники нормализации — данные с редакциями форм», D-7).""" + +from datetime import date +from pathlib import Path + +import pytest +import yaml + +from src.normalizer.rules import Edition, OcrRules, load_rules + +API_DIR = Path(__file__).resolve().parents[3] +RULES_DIR = API_DIR / "rules" + + +def _write_rules(tmp_path: Path, lines: dict | None = None) -> Path: + (tmp_path / "statement_lines.yaml").write_text( + yaml.safe_dump( + lines + or { + "editions": [ + {"id": "66n", "valid_until": "2024-12-31", "lines": {"1150": "Основные средства"}}, + {"id": "fsbu4", "valid_from": "2025-01-01", "lines": {}}, + ] + }, + allow_unicode=True, + ), + encoding="utf-8", + ) + (tmp_path / "ocr_lookalikes.yaml").write_text(yaml.safe_dump({"digits": {"О": "0", "l": "1"}}), encoding="utf-8") + (tmp_path / "statement_labels.yaml").write_text( + yaml.safe_dump({"labels": {"бухгалтерский баланс": "BALANCE"}}, allow_unicode=True), encoding="utf-8" + ) + return tmp_path + + +class TestRealRules: + def test_loads_repository_rules(self) -> None: + rules = load_rules(RULES_DIR) + ids = [edition.id for edition in rules.editions] + assert ids == ["66n", "fsbu4"] + assert rules.editions[0].lines["1150"] == "Основные средства" + assert rules.editions[0].lines["1120"] == "Результаты исследований и разработок" + assert rules.lookalikes["О"] == "0" + assert rules.labels["бухгалтерский баланс"] == "BALANCE" + assert rules.labels["отчет о финансовых результатах"] == "INCOME" + + def test_codes_are_four_or_five_digits_without_duplicates(self) -> None: + rules = load_rules(RULES_DIR) + for edition in rules.editions: + codes = list(edition.lines) + assert all(code.isdigit() and 4 <= len(code) <= 5 for code in codes), edition.id + assert len(codes) == len(set(codes)), edition.id + assert all(name.strip() for name in edition.lines.values()), edition.id + + def test_fsbu4_edition_exists_even_if_empty(self) -> None: + rules = load_rules(RULES_DIR) + fsbu4 = next(edition for edition in rules.editions if edition.id == "fsbu4") + assert fsbu4.valid_from == date(2025, 1, 1) + + +class TestEditionChoice: + def test_by_report_date(self, tmp_path: Path) -> None: + rules = load_rules(_write_rules(tmp_path)) + assert rules.edition_for(date(2024, 12, 31)).id == "66n" + assert rules.edition_for(date(2025, 6, 30)).id == "fsbu4" + assert rules.edition_for(None) is None + + def test_by_codes_prefers_more_hits(self, tmp_path: Path) -> None: + rules = load_rules( + _write_rules( + tmp_path, + { + "editions": [ + {"id": "66n", "valid_until": "2024-12-31", "lines": {"1150": "ОС", "1110": "НМА"}}, + {"id": "fsbu4", "valid_from": "2025-01-01", "lines": {"1150": "ОС", "1231": "ДЗ долгосрочная"}}, + ] + }, + ) + ) + assert rules.edition_by_codes(["1150", "1231"]).id == "fsbu4" + assert rules.edition_by_codes(["1150"]).id == "66n" # равенство — первая + assert rules.edition_by_codes(["9999"]) is None + + def test_known_codes_and_editions_with(self, tmp_path: Path) -> None: + rules = load_rules(_write_rules(tmp_path)) + assert rules.known_codes() == frozenset({"1150"}) + assert rules.editions_with("1150") == ["66n"] + assert rules.editions_with("1110") == [] + + +class TestValidation: + def test_bad_code_fails_with_file_name(self, tmp_path: Path) -> None: + _write_rules(tmp_path, {"editions": [{"id": "66n", "lines": {"11A0": "x"}}]}) + with pytest.raises(ValueError, match="statement_lines.yaml"): + load_rules(tmp_path) + + def test_missing_file_fails(self, tmp_path: Path) -> None: + _write_rules(tmp_path) + (tmp_path / "ocr_lookalikes.yaml").unlink() + with pytest.raises(ValueError, match="ocr_lookalikes.yaml"): + load_rules(tmp_path) + + def test_lookalike_must_map_to_single_digit(self, tmp_path: Path) -> None: + _write_rules(tmp_path) + (tmp_path / "ocr_lookalikes.yaml").write_text(yaml.safe_dump({"digits": {"О": "00"}}), encoding="utf-8") + with pytest.raises(ValueError, match="ocr_lookalikes.yaml"): + load_rules(tmp_path) + + +def test_settings_defaults() -> None: + from src.core.config import get_settings + + settings = get_settings() + assert settings.SCAN_CLEANUP is False + assert settings.SCAN_RULES_DIR == "rules" + assert isinstance(Edition, type) and isinstance(OcrRules, type) +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `cd api && .venv/bin/pytest tests/unit/normalizer/test_rules.py -q` +Expected: `ImportError: cannot import name 'load_rules'`. + +- [ ] **Step 3: Написать yaml-справочники** + +`api/rules/statement_lines.yaml` (редакция `66n` — из `codes_mapping` старого ядра; опечатки исправлены: 1120 +«разработок», 1220 — «Налог на добавленную стоимость по приобретённым ценностям»; служебные `Код`, `1000`, `2000` не включаются): + +```yaml +# Справочник «код строки → наименование показателя» форм ББ/ОФР по редакциям (§11.1 п. 6, D-7). +# Редакция выбирается по дате отчёта документа: valid_until / valid_from — включительно. +# 66n — Приказ Минфина № 66н (отчётность до 2024 года включительно), источник — codes_mapping старого ядра DKB_ARMKA +# (проверен на стенде ИС Сканирование). fsbu4 — ФСБУ 4/2023 (отчётность за 2025 год и позже): заполняет владелец +# по Приложениям 3–4 ФСБУ 4/2023; пока пуста, документы 2025 года получают имена из OCR и предупреждение. +editions: + - id: 66n + valid_until: 2024-12-31 + lines: + "1105": "Гудвил" + "1100": "Итого по разделу I" + "1110": "Нематериальные активы" + "1120": "Результаты исследований и разработок" + "1130": "Нематериальные поисковые активы" + "1140": "Материальные поисковые активы" + "1150": "Основные средства" + "1160": "Инвестиционная недвижимость" + "1170": "Финансовые вложения" + "1180": "Отложенные налоговые активы" + "1190": "Прочие внеоборотные активы" + "1200": "Итого по разделу II" + "1210": "Запасы" + "1215": "Долгосрочные активы к продаже" + "1220": "Налог на добавленную стоимость по приобретённым ценностям" + "1230": "Дебиторская задолженность" + "1240": "Финансовые вложения (за исключением денежных эквивалентов)" + "1250": "Денежные средства и денежные эквиваленты" + "1260": "Прочие оборотные активы" + "1600": "БАЛАНС" + "1300": "Итого по разделу III" + "1310": "Уставный капитал" + "1320": "Собственные акции, выкупленные у акционеров" + "1330": "Целевые средства" + "1340": "Переоценка внеоборотных активов" + "1350": "Добавочный капитал (без переоценки)" + "1360": "Резервный капитал" + "1370": "Нераспределенная прибыль (непокрытый убыток)" + "1400": "Итого по разделу IV" + "1410": "Заемные средства" + "1420": "Отложенные налоговые обязательства" + "1430": "Оценочные обязательства" + "1450": "Прочие обязательства" + "1500": "Итого по разделу V" + "1510": "Заемные средства" + "1520": "Кредиторская задолженность" + "1530": "Доходы будущих периодов" + "1540": "Оценочные обязательства" + "1550": "Прочие обязательства" + "1700": "БАЛАНС" + "2110": "Выручка" + "2120": "Себестоимость продаж" + "2100": "Валовая прибыль (убыток)" + "2210": "Коммерческие расходы" + "2220": "Управленческие расходы" + "2200": "Прибыль (убыток) от продаж" + "2310": "Доходы от участия в других организациях" + "2320": "Проценты к получению" + "2330": "Проценты к уплате" + "2340": "Прочие доходы" + "2350": "Прочие расходы" + "2300": "Прибыль (убыток) до налогообложения" + "2410": "Налог на прибыль" + "2411": "в т.ч. текущий налог на прибыль" + "2412": "отложенный налог на прибыль" + "2460": "Прочее" + "2400": "Чистая прибыль (убыток)" + "2510": "Результат от переоценки внеоборотных активов, не включаемый в чистую прибыль (убыток) периода" + "2520": "Результат от прочих операций, не включаемый в чистую прибыль (убыток) периода" + "2530": "Налог на прибыль от операций, результат которых не включается в чистую прибыль (убыток) периода" + "2500": "Совокупный финансовый результат периода" + "2900": "Базовая прибыль (убыток) на акцию" + "2910": "Разводненная прибыль (убыток) на акцию" + - id: fsbu4 + valid_from: 2025-01-01 + lines: {} +``` + +`api/rules/ocr_lookalikes.yaml` (из `date_utils._LOOKALIKES`): + +```yaml +# Буквы и знаки, которые OCR ставит вместо цифр (§11.1 п. 1). Замена — только вплотную к цифре, в несколько проходов. +digits: + "О": "0" + "о": "0" + "O": "0" + "o": "0" + "З": "3" + "з": "3" + "l": "1" + "I": "1" + "|": "1" + "і": "1" + "б": "6" + "S": "5" + "s": "5" + "Ч": "4" +``` + +`api/rules/statement_labels.yaml`: + +```yaml +# Нормализованные (нижний регистр, не-буквенно-цифровое → пробел) подстроки label таблицы → тип документа (§11 / D-6). +labels: + "бухгалтерский баланс": BALANCE + "отчет о финансовых результатах": INCOME + "отчёт о финансовых результатах": INCOME + "отчет о прибылях и убытках": INCOME +``` + +- [ ] **Step 4: Настройки** + +В `api/src/core/config.py` после строки `SCAN_MAX_INFLIGHT: PositiveInt = 4 …` добавить: + +```python + # Очистка горячей папки после прочтения набора ответа (D-2): любое удаление, включая .part в Tmp, только при true. + # На SMB-шаре контура права на удаление не подтверждены — по умолчанию выключено + SCAN_CLEANUP: bool = False + SCAN_RULES_DIR: str = "rules" # справочники нормализатора (yaml), путь от текущего каталога (api/) +``` + +- [ ] **Step 5: Модуль правил** + +`api/src/normalizer/rules.py`: + +```python +"""Справочники нормализатора OCR (D-7, §11.1 п. 1, 6): yaml в `SCAN_RULES_DIR`, загрузка один раз при старте. + +`statement_lines.yaml` — редакции форм (`66n` до 2024-12-31, `fsbu4` с 2025-01-01) с таблицей код → наименование; +`ocr_lookalikes.yaml` — двойники букв и цифр; `statement_labels.yaml` — подстроки `label` таблицы → тип документа. +Битый файл — `ValueError` с именем файла (сервис не стартует). +""" + +from collections.abc import Iterable +from datetime import date +from pathlib import Path +from typing import Literal + +import yaml +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator + +__all__ = ["Edition", "OcrRules", "load_rules"] + +LINES_FILE = "statement_lines.yaml" +LOOKALIKES_FILE = "ocr_lookalikes.yaml" +LABELS_FILE = "statement_labels.yaml" +DocumentKind = Literal["BALANCE", "INCOME"] + + +class Edition(BaseModel): + """Редакция форм: интервал дат отчёта (включительно, открытый край — `None`) и таблица код → наименование.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + id: str = Field(min_length=1) + valid_from: date | None = None + valid_until: date | None = None + lines: dict[str, str] = Field(default_factory=dict) + + @field_validator("lines") + @classmethod + def _check_lines(cls, lines: dict[str, str]) -> dict[str, str]: + for code, name in lines.items(): + if not (code.isdigit() and 4 <= len(code) <= 5): # noqa: PLR2004 — коды строк форм: 4–5 цифр + raise ValueError(f"код строки {code!r}: ожидаются 4–5 цифр") + if not name.strip(): + raise ValueError(f"код строки {code!r}: пустое наименование") + return lines + + def covers(self, report_date: date) -> bool: + return (self.valid_from is None or self.valid_from <= report_date) and ( + self.valid_until is None or report_date <= self.valid_until + ) + + +class _LinesFile(BaseModel): + model_config = ConfigDict(extra="forbid") + editions: list[Edition] = Field(min_length=1) + + @model_validator(mode="after") + def _unique_ids(self) -> "_LinesFile": + ids = [edition.id for edition in self.editions] + if len(ids) != len(set(ids)): + raise ValueError(f"id редакций повторяются: {ids}") + return self + + +class _LookalikesFile(BaseModel): + model_config = ConfigDict(extra="forbid") + digits: dict[str, str] + + @field_validator("digits") + @classmethod + def _single_chars(cls, digits: dict[str, str]) -> dict[str, str]: + for char, digit in digits.items(): + if len(char) != 1 or len(digit) != 1 or not digit.isdigit(): + raise ValueError(f"двойник {char!r} -> {digit!r}: ожидается один символ и одна цифра") + return digits + + +class _LabelsFile(BaseModel): + model_config = ConfigDict(extra="forbid") + labels: dict[str, DocumentKind] + + +class OcrRules(BaseModel): + """Все справочники нормализатора; передаётся в конвейер параметром.""" + + model_config = ConfigDict(frozen=True) + + editions: list[Edition] + lookalikes: dict[str, str] + labels: dict[str, DocumentKind] + + def edition_for(self, report_date: date | None) -> Edition | None: + """Редакция по дате отчёта (первая, чей интервал покрывает дату); `None` без даты или вне интервалов.""" + if report_date is None: + return None + return next((edition for edition in self.editions if edition.covers(report_date)), None) + + def edition_by_codes(self, codes: Iterable[str]) -> Edition | None: + """Редакция с наибольшим числом известных кодов; при равенстве — первая по порядку; `None`, если совпадений нет.""" + wanted = set(codes) + best: Edition | None = None + best_hits = 0 + for edition in self.editions: + hits = len(wanted & edition.lines.keys()) + if hits > best_hits: + best, best_hits = edition, hits + return best + + def known_codes(self) -> frozenset[str]: + return frozenset(code for edition in self.editions for code in edition.lines) + + def editions_with(self, code: str) -> list[str]: + return [edition.id for edition in self.editions if code in edition.lines] + + +def _read_yaml(directory: Path, name: str) -> object: + path = directory / name + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + raise ValueError(f"справочник {name} не прочитан ({path}): {exc}") from exc + try: + return yaml.safe_load(text) + except yaml.YAMLError as exc: + raise ValueError(f"справочник {name} не разобран как YAML: {exc}") from exc + + +def load_rules(directory: Path | str) -> OcrRules: + """Загрузить три справочника из каталога; `ValueError` с именем файла при любой ошибке.""" + base = Path(directory) + loaded: dict[str, BaseModel] = {} + for name, model in ((LINES_FILE, _LinesFile), (LOOKALIKES_FILE, _LookalikesFile), (LABELS_FILE, _LabelsFile)): + try: + loaded[name] = model.model_validate(_read_yaml(base, name)) + except ValidationError as exc: + raise ValueError(f"справочник {name}: {exc}") from exc + lines = loaded[LINES_FILE] + lookalikes = loaded[LOOKALIKES_FILE] + labels = loaded[LABELS_FILE] + assert isinstance(lines, _LinesFile) and isinstance(lookalikes, _LookalikesFile) # noqa: S101 — сужение типов + assert isinstance(labels, _LabelsFile) # noqa: S101 + return OcrRules(editions=lines.editions, lookalikes=lookalikes.digits, labels=labels.labels) +``` + +Если `mypy` ругается на `yaml` без stubs — добавить в `api/requirements-dev.txt` строку `types-PyYAML==6.0.12.20250915` +и выполнить `make venv`. + +- [ ] **Step 6: Запустить тесты и линт** + +Run: `cd api && .venv/bin/pytest tests/unit/normalizer/test_rules.py -q && cd .. && make lint` +Expected: все PASS, линт чист. + +- [ ] **Step 7: Commit** + +```bash +git add api/rules api/src/normalizer/rules.py api/src/core/config.py api/tests/unit/normalizer api/requirements-dev.txt +git commit -m "feat(normalizer): справочники yaml с редакциями форм, загрузчик правил, SCAN_CLEANUP/SCAN_RULES_DIR (D-7)" +``` + +--- + +### Task 2: Очистка текста (`src/normalizer/ocr/text.py`) + +**Files:** +- Create: `api/src/normalizer/ocr/__init__.py` (пока пустой, с docstring), `api/src/normalizer/ocr/text.py` +- Test: `api/tests/unit/normalizer/test_text.py` + +**Interfaces:** +- Produces: + - `fix_lookalikes(text: str, lookalikes: Mapping[str, str]) -> str` — перенос `date_utils._normalize`: `|` как + разделитель клеток (если первая строка состоит из `[\s\d|•·.:,]`), затем замена двойников вплотную к цифре в + несколько проходов. + - `clean_value(text: str) -> str` — перенос `_clear_value`: `\n`/`\t` → пробел, остаются буквы, цифры, пробел, точка; `strip`. + - `digits_only(text: str) -> str` — перенос `_keep_digits_only`. + - `collapse_whitespace(text: str) -> str` — перенос `_remove_spec_space` (`\n`/`\t` → пробел, `strip`). + - `normalize_header(text: object) -> str` — перенос `_normalize_header`: `str(text or "").lower()`, не-буквенно-цифровое → пробел, схлопнуть пробелы. + - `is_code_header(text: str) -> bool` (`text.startswith("код")` на нормализованном тексте), `is_value_header(text: str) -> bool` + (есть 4 цифры подряд или начинается с «на » / «за »). + +- [ ] **Step 1: Падающие тесты** — `api/tests/unit/normalizer/test_text.py`: + +```python +"""Очистка OCR-текста (§11.1 п. 1, 6; D-4 `text.py`): перенос функций старого ядра, поведение — как в источнике.""" + +import pytest + +from src.normalizer.ocr.text import ( + clean_value, + collapse_whitespace, + digits_only, + fix_lookalikes, + is_code_header, + is_value_header, + normalize_header, +) + +LOOKALIKES = {"О": "0", "о": "0", "O": "0", "o": "0", "З": "3", "з": "3", "l": "1", "I": "1", "|": "1", "і": "1", + "б": "6", "S": "5", "s": "5", "Ч": "4"} + + +class TestFixLookalikes: + @pytest.mark.parametrize( + ("raw", "expected"), + [ + ("2ОО5", "2005"), # два прохода: О после 2, затем О после 0 + ("3l.12.2О24", "31.12.2024"), + ("Общество", "Общество"), # буквы без соседней цифры не трогаются + ("2|0|2|4|", "2 0 2 4 "), # первая строка — только цифры и |: это разделитель клеток + ("3l", "31"), # | не разделитель, если есть буквы + ("", ""), + ], + ) + def test_replaces_only_next_to_digits(self, raw: str, expected: str) -> None: + assert fix_lookalikes(raw, LOOKALIKES) == expected + + def test_separator_rule_looks_at_first_line_only(self) -> None: + assert fix_lookalikes("2|0|2|4|\nмусор", LOOKALIKES) == "2 0 2 4 \nмусор" + + def test_empty_table_keeps_text(self) -> None: + assert fix_lookalikes("2ОО5", {}) == "2ОО5" + + +@pytest.mark.parametrize( + ("raw", "expected"), + [("13 070", "13 070"), ("(7744536}", "7744536"), ("изо :", "изо"), ("На 30 июня\n2025 г", "На 30 июня 2025 г"), ("", "")], +) +def test_clean_value(raw: str, expected: str) -> None: + assert clean_value(raw) == expected + + +def test_digits_only() -> None: + assert digits_only("3|1 21 2| о! О' 0 0; 3: 51") == "3121200351" + assert digits_only("+") == "" + + +def test_collapse_whitespace() -> None: + assert collapse_whitespace(" На 31 декабря\n2024 г.\t") == "На 31 декабря 2024 г." + + +class TestHeaders: + def test_normalize_header(self) -> None: + assert normalize_header("Наименование\nпоказателя:") == "наименование показателя" + assert normalize_header(None) == "" + + def test_code_header(self) -> None: + assert is_code_header(normalize_header("Код")) + assert is_code_header(normalize_header("Код строки")) + assert not is_code_header(normalize_header("Наименование")) + + @pytest.mark.parametrize( + ("text", "expected"), + [("на 30 июня 2025 г", True), ("за январь июнь 2025 г", True), ("2024", True), ("пояснения", False), ("", False)], + ) + def test_value_header(self, text: str, expected: bool) -> None: + assert is_value_header(text) is expected +``` + +- [ ] **Step 2: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/unit/normalizer/test_text.py -q` → `ModuleNotFoundError: src.normalizer.ocr`. + +- [ ] **Step 3: Реализация** + +`api/src/normalizer/ocr/__init__.py` (пока только docstring; `OcrNormalizer` появится в Task 9): + +```python +"""Нормализатор OCR: конвейер чистых функций `text` → `dates` → `amounts` → `layout` → `rows` → `header` → `document` +(D-4, §11.1). Проверенные на стенде функции старого ядра (`../reference-old-arm/api/src/core/PDFWorker/`) перенесены +дословно; DB-обвязка не переносится.""" +``` + +`api/src/normalizer/ocr/text.py`: + +```python +"""Очистка OCR-текста (§11.1 п. 1, 6). Перенос из `date_utils._normalize`, `utils._clear_value`, `_keep_digits_only`, +`_remove_spec_space`, `_normalize_header`, `_is_code_header`, `_is_value_header` старого ядра; алгоритмы без изменений.""" + +import re +from collections.abc import Mapping + +__all__ = [ + "clean_value", + "collapse_whitespace", + "digits_only", + "fix_lookalikes", + "is_code_header", + "is_value_header", + "normalize_header", +] + +_YEAR_RE = re.compile(r"\d{4}") +# первая строка значения из цифр, пробелов, `|`, точек и т. п. — `|` в ней разделяет клетки формы, а не единица +_CELL_SEPARATOR_LINE = re.compile(r"[\s\d|•·.:,]*") + + +def fix_lookalikes(text: str, lookalikes: Mapping[str, str]) -> str: + """Буквы-двойники → цифры только вплотную к цифре, в несколько проходов («2ОО5» → «2005»); `|` — разделитель + клеток, если первая строка состоит из цифр, пробелов и пунктуации («2|0|2|4|» → «2 0 2 4 »).""" + text = text.replace(" ", " ") + if _CELL_SEPARATOR_LINE.fullmatch(text.split("\n", 1)[0]): + text = text.replace("|", " ") + if not lookalikes: + return text + chars = re.escape("".join(lookalikes)) + near_digit = re.compile(rf"(?<=\d)[{chars}]|[{chars}](?=\d)") + table = str.maketrans(dict(lookalikes)) + previous = None + while previous != text: # «2ОО5» требует двух проходов + previous = text + text = near_digit.sub(lambda match: match.group(0).translate(table), text) + return text + + +def clean_value(text: str) -> str: + """Остаются буквы, цифры, пробел и точка; переносы строк и табуляции — пробел; края обрезаются.""" + cleaned = "".join( + char for char in text.replace("\n", " ").replace("\t", " ") if char.isdigit() or char.isalpha() or char in " ." + ) + return cleaned.strip() + + +def digits_only(text: str) -> str: + return "".join(char for char in text if char.isdigit()) + + +def collapse_whitespace(text: str) -> str: + return text.replace("\n", " ").replace("\t", " ").strip() + + +def normalize_header(text: object) -> str: + """Нижний регистр, всё не-буквенно-цифровое → пробел, пробелы схлопнуты (для сравнения заголовков и label).""" + lowered = str(text or "").lower() + return " ".join("".join(char if char.isalnum() else " " for char in lowered).split()) + + +def is_code_header(text: str) -> bool: + """Ячейка шапки «Код…» (текст уже нормализован `normalize_header`).""" + return text.startswith("код") + + +def is_value_header(text: str) -> bool: + """Ячейка шапки колонки значений: есть четыре цифры года или текст начинается с «на » / «за ».""" + return bool(_YEAR_RE.search(text)) or text.startswith(("на ", "за ")) +``` + +Примечание к переносу: в источнике `_normalize` заменял `" "` (неразрывный пробел U+00A0) на обычный — сохранено; +таблица двойников в источнике была константой, здесь приходит из `ocr_lookalikes.yaml` (D-7). + +- [ ] **Step 4: Тесты и линт** — `cd api && .venv/bin/pytest tests/unit/normalizer/test_text.py -q && cd .. && make lint` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add api/src/normalizer/ocr api/tests/unit/normalizer/test_text.py +git commit -m "feat(normalizer): очистка OCR-текста — двойники, разделитель клеток, clean_value (§11.1 п. 1, 6)" +``` + +--- + +### Task 3: Разбор дат (`src/normalizer/ocr/dates.py`) + +**Files:** +- Create: `api/src/normalizer/ocr/dates.py` +- Test: `api/tests/unit/normalizer/test_dates.py` + +**Interfaces:** +- Produces: `parse_date(text: object) -> date | None` — текст **уже после** `fix_lookalikes`; не-строка и пустая строка → `None`. + Текстовые формы по всей строке в порядке приоритета, затем числовые по первой строке, затем по всему тексту с `\n` → пробел + (точно как `date_utils._parse_date`, только без внутреннего `_normalize`). + +- [ ] **Step 1: Падающие тесты** — `api/tests/unit/normalizer/test_dates.py`: + +```python +"""Разбор дат из OCR-текста (§11.1 п. 2): дословный перенос `date_utils` старого ядра, вход — после `fix_lookalikes`.""" + +from datetime import date + +import pytest + +from src.normalizer.ocr.dates import parse_date +from src.normalizer.ocr.text import fix_lookalikes + +LOOKALIKES = {"О": "0", "о": "0", "З": "3", "l": "1", "I": "1", "|": "1", "б": "6", "S": "5", "Ч": "4"} + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("на 30 сентября 2025 г.", date(2025, 9, 30)), + ("На 30 июня\n2025 г", date(2025, 6, 30)), + ("31 декабря 2024 года", date(2024, 12, 31)), + ("30 сентября 2025 r.", date(2025, 9, 30)), # латинская r вместо г + ("с 01.01.2025 по 30.09.2025", date(2025, 9, 30)), # диапазон — конец + ("I квартал 2026", date(2026, 3, 31)), + ("1-й кв. 2026", date(2026, 3, 31)), + ("первый квартал 2025 г.", date(2025, 3, 31)), + ("1 полугодие 2026", date(2026, 6, 30)), + ("1 п/г 2026", date(2026, 6, 30)), + ("9 месяцев 2025", date(2025, 9, 30)), + ("9 мес. 2025", date(2025, 9, 30)), + ("За январь - июнь\n2025 г.", date(2025, 6, 30)), + ("январь – сентябрь 2025", date(2025, 9, 30)), + ("за 2025 г.", date(2025, 12, 31)), + ("2024 год", date(2024, 12, 31)), + ("30.09.2025", date(2025, 9, 30)), + ("2025 03 31", date(2025, 3, 31)), + ("30/09/25", date(2025, 9, 30)), + ("2 0 2 4", date(2024, 12, 31)), + ("30 09 2 0 2 5", date(2025, 9, 30)), + ("30092025", date(2025, 9, 30)), + ("20250930", date(2025, 9, 30)), + ("30 09 25", date(2025, 9, 30)), + ("30.09.2025 0:00:00", date(2025, 9, 30)), + ("2|0|2|4|", date(2024, 12, 31)), # через fix_lookalikes + ("3l.12.2О24", date(2024, 12, 31)), + ], +) +def test_parses_real_forms(raw: str, expected: date) -> None: + assert parse_date(fix_lookalikes(raw, LOOKALIKES)) == expected + + +@pytest.mark.parametrize( + "raw", + [ + "Общество с ограниченной ответственностью", # Review Focus 3: название организации — не дата + "ЗАКРЫТОЕ АКЦИОНЕРНОЕ ОБЩЕСТВО", + "", + "+", + "тыс. руб.", + "1999999999999", # 13 цифр — ни одна форма + ], +) +def test_organization_name_is_not_a_date(raw: str) -> None: + assert parse_date(fix_lookalikes(raw, LOOKALIKES)) is None + + +def test_non_string_is_none() -> None: + assert parse_date(None) is None + assert parse_date(20250930) is None + + +def test_year_out_of_range_is_none() -> None: + assert parse_date("1 2 3 4") is None # 1234 вне 1990–2100 +``` + +- [ ] **Step 2: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/unit/normalizer/test_dates.py -q` → `ModuleNotFoundError`. + +- [ ] **Step 3: Перенос `date_utils.py`** + +Скопировать `/Users/andrey.epifancev/projects/awp-ca/reference-old-arm/api/src/core/PDFWorker/date_utils.py` в +`api/src/normalizer/ocr/dates.py` и внести ровно эти правки: +1. Docstring модуля: `"""Разбор дат из OCR-текста (§11.1 п. 2). Дословный перенос `date_utils` старого ядра DKB_ARMKA + (проверен на стенде ИС Сканирование): текстовые формы по приоритету, затем числовые. Вход — текст после + `text.fix_lookalikes`; замена двойников здесь не выполняется."""` и `__all__ = ["parse_date"]`. +2. Удалить `_LOOKALIKES`, `_LOOKALIKE_CHARS`, `_NEAR_DIGIT` и функцию `_normalize` (они перенесены в `text.fix_lookalikes`). +3. `_parse_date` переименовать в `parse_date` с сигнатурой `def parse_date(value: object) -> date | None:` и телом: + +```python + if not isinstance(value, str): + return None + s = value.strip() + if not s: + return None + # текстовые формы ищем по всей строке, числовые — по первой (дальше мусор) + return _text_date(s) or _numeric_date(s.split("\n", 1)[0]) or _numeric_date(s.replace("\n", " ")) +``` +4. `Optional[...]` → `... | None`, комментарии оставить. Больше ничего не менять: `_ROMAN`, `_ORDINAL`, `_MONTHS`, + `_Y`, `_MONTH_WORD`, `_NUM`, `_PATTERNS`, `_period`, `_safe`, `_text_date`, `_numeric_date`, `_from_digits`, + `_from_parts`, `_year`, `_month_num`, `_month_end`, `_year_full`, `_qh` — как в источнике. +5. Если `ruff` требует: `# noqa: PLR2004` у сравнений `len(groups) == 3`, `len(digits) == 8/4/6`, `y > 100`; + `# noqa: E741` не нужен. `mypy`: аннотировать `_PATTERNS: list[tuple[re.Pattern[str], Callable[[re.Match[str]], date | None]]]`. + +- [ ] **Step 4: Тесты и линт** — `cd api && .venv/bin/pytest tests/unit/normalizer/test_dates.py -q && cd .. && make lint` → PASS. +Если какой-то параметр падает — сверить с источником: расхождение означает ошибку переноса, а не повод править тест. + +- [ ] **Step 5: Commit** + +```bash +git add api/src/normalizer/ocr/dates.py api/tests/unit/normalizer/test_dates.py +git commit -m "feat(normalizer): разбор дат из OCR — дословный перенос date_utils старого ядра (§11.1 п. 2)" +``` + +--- + +### Task 4: Суммы (`src/normalizer/ocr/amounts.py`) + +**Files:** +- Create: `api/src/normalizer/ocr/amounts.py` +- Test: `api/tests/unit/normalizer/test_amounts.py` + +**Interfaces:** +- Produces: `@dataclass(frozen=True) ParsedAmount(amount: Decimal | None, verified: bool)`; + `parse_amount(raw: str) -> ParsedAmount` (перенос `_parse_amount`); `is_blank(text: str) -> bool` (перенос `_is_blank`: + пусто после удаления пробелов и прочерков `-–—`). + +- [ ] **Step 1: Падающие тесты** — `api/tests/unit/normalizer/test_amounts.py`: + +```python +"""Суммы из OCR-ячеек (§11.1 п. 7): перенос `_parse_amount`/`_is_blank` старого ядра.""" + +from decimal import Decimal + +import pytest + +from src.normalizer.ocr.amounts import ParsedAmount, is_blank, parse_amount + + +@pytest.mark.parametrize( + ("raw", "amount", "verified"), + [ + ("13 070", Decimal("13070"), True), + ("(6032409)", Decimal("-6032409"), True), + ("(7744536}", Decimal("7744536"), False), # закрывающая скобка не распознана: минус не ставится (решение №12) + ("1 234,56", Decimal("1234.56"), True), + ("1.234.567", Decimal("1234567"), False), # несколько точек — разделители тысяч; текст изменён → не verified + ("30127", Decimal("30127"), True), + ("211(1 |", Decimal("2111"), False), + ("не ****", None, False), + ("", None, True), + ("--", None, True), # прочерк: ничего не отброшено, суммы нет (NULL) + (":", None, False), + ], +) +def test_parse_amount(raw: str, amount: Decimal | None, verified: bool) -> None: + assert parse_amount(raw) == ParsedAmount(amount, verified) + + +@pytest.mark.parametrize(("raw", "expected"), [("", True), ("-", True), ("—", True), (" – ", True), ("0", False), ("x", False)]) +def test_is_blank(raw: str, expected: bool) -> None: + assert is_blank(raw) is expected +``` + +- [ ] **Step 2: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/unit/normalizer/test_amounts.py -q`. + +- [ ] **Step 3: Реализация** — `api/src/normalizer/ocr/amounts.py`: + +```python +"""Суммы из OCR-ячеек (§11.1 п. 7). Перенос `_parse_amount`, `_is_blank` и регулярок старого ядра без изменений: +запятая → точка, пробелы долой, содержимое круглых скобок — отрицательное, прочие символы отбрасываются, несколько +точек — разделители тысяч (остаётся последняя). `verified` — разбор не отбрасывал символов.""" + +import re +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation + +__all__ = ["ParsedAmount", "is_blank", "parse_amount"] + +_BLANK_CHARS_RE = re.compile(r"[\s\-–—]") +_ALLOWED_RE = re.compile(r"[^\d+\-.]") +_BRACKET_RE = re.compile(r"\(([^()]*)\)") + + +@dataclass(frozen=True) +class ParsedAmount: + amount: Decimal | None + verified: bool + + +def parse_amount(raw: str) -> ParsedAmount: + s = raw.strip().replace(",", ".").replace(" ", "") + stripped_raw = s + if not stripped_raw: + return ParsedAmount(None, stripped_raw == raw) + match = _BRACKET_RE.search(s) + bracketed = match is not None + if match is not None: + s = match.group(1) + s = _ALLOWED_RE.sub("", s) + if s.count(".") > 1: + *head, tail = s.split(".") + s = "".join(head) + tail + # verified считается ДО постановки знака (отличие от источника, решение №13 design.md): в источнике сравнение шло + # после `s = "-" + s`, и любая сумма в скобках получала verified = False, то есть is_recognized = false + verified = s == stripped_raw.replace(")", "").replace("(", "") + if bracketed: + s = "-" + s.lstrip("+-") + try: + return ParsedAmount(Decimal(s), verified) + except (InvalidOperation, ValueError): + return ParsedAmount(None, verified) + + +def is_blank(text: str) -> bool: + """Прочерк в отчётности означает ноль — считаем пустым значением (NULL, не 0).""" + return not _BLANK_CHARS_RE.sub("", text) +``` + +Примечание: в источнике `except:` без типа; здесь `InvalidOperation, ValueError` (`Decimal("")` и `Decimal("-")` дают `InvalidOperation`). +Единственное осознанное отличие от источника — порядок `verified`/знака (решение №13 в `design.md`); остальное дословно. +Проверить параметр `("", None, True)`: `raw=""` → `stripped_raw == raw` → `True` — как в источнике. + +- [ ] **Step 4: Тесты и линт** — PASS. Если `("211(1 |", …)` расходится — сверить с источником пошагово (`(1 |` → скобка не закрыта → `ALLOWED_RE` удаляет `(`, пробел уже удалён, `|` удалён → `2111`). + +- [ ] **Step 5: Commit** + +```bash +git add api/src/normalizer/ocr/amounts.py api/tests/unit/normalizer/test_amounts.py +git commit -m "feat(normalizer): разбор сумм OCR — перенос _parse_amount (§11.1 п. 7)" +``` + +--- + +### Task 5: Раскладка колонок (`src/normalizer/ocr/layout.py`) + +**Files:** +- Create: `api/src/normalizer/ocr/layout.py` +- Test: `api/tests/unit/normalizer/test_layout.py` + +**Interfaces:** +- Produces: + - `@dataclass(frozen=True) Cell(value: str, confidence: float)`; `Cells = dict[int, Cell]`. + - `cells_by_index(row: Mapping[str, object]) -> Cells` — из `{"row_index", "cells": [{"column_index", "value", "confidence"}]}`; + `value` → `str(value or "")`, `confidence` → `float(confidence or 0)`; ячейка без `column_index` пропускается. + - `cell_text(cells: Cells, idx: int | None) -> str` (перенос `_cell_value`). + - `@dataclass(frozen=True) ColumnLayout(label_idx: int | None, code_idx: int, value_idxs: tuple[int, ...])`. + - `build_layout(cells: Cells) -> ColumnLayout | None` (перенос `_build_layout`). + - `infer_layout(rows_cells: Sequence[Cells], known_codes: Collection[str]) -> ColumnLayout | None` (перенос `_infer_layout`, + справочник кодов — параметр). + +- [ ] **Step 1: Падающие тесты** — `api/tests/unit/normalizer/test_layout.py`: + +```python +"""Раскладка колонок таблицы OCR (§11.1 п. 3): шапка «Код» и вывод по данным — перенос `_build_layout`/`_infer_layout`.""" + +from src.normalizer.ocr.layout import Cell, ColumnLayout, build_layout, cell_text, cells_by_index, infer_layout + +KNOWN = frozenset({"1110", "1120", "1130", "1150", "2110", "2120"}) + + +def _row(*cells: tuple[int, str, float]) -> dict: + return {"row_index": 0, "cells": [{"column_index": i, "value": v, "confidence": c} for i, v, c in cells]} + + +def test_cells_by_index_normalizes_types() -> None: + cells = cells_by_index(_row((0, "Выручка", 71.4), (1, None, None))) + assert cells == {0: Cell("Выручка", 71.4), 1: Cell("", 0.0)} + assert cell_text(cells, 0) == "Выручка" + assert cell_text(cells, 7) == "" + assert cell_text(cells, None) == "" + + +class TestBuildLayout: + def test_header_with_code_and_years(self) -> None: + cells = cells_by_index( + _row((0, "Наименование показателя", 100), (1, "Код", 100), (2, "На 30 июня\n2025 г", 100), + (4, "На 31 декабря\n2024 г.", 100), (5, "На 31 декабря\n2023 г.", 100)) + ) + assert build_layout(cells) == ColumnLayout(label_idx=0, code_idx=1, value_idxs=(2, 4, 5)) + + def test_label_defaults_to_last_filled_left_of_code(self) -> None: + cells = cells_by_index(_row((0, "Пояснения", 100), (1, "Показатель", 100), (2, "Код", 100), (3, "2024", 100))) + assert build_layout(cells) == ColumnLayout(label_idx=1, code_idx=2, value_idxs=(3,)) + + def test_no_code_cell_is_not_a_header(self) -> None: + assert build_layout(cells_by_index(_row((0, "Выручка", 100), (1, "2110", 100), (2, "13 070", 100)))) is None + + +class TestInferLayout: + def test_aep3_like_rows_without_header(self) -> None: + rows = [ + cells_by_index(_row((1, "НИ", 0), (0, "Нематериальные активы", 100), (2, "30127", 60), (4, "| | г", 16), (5, "", 100))), + cells_by_index(_row((1, "1120", 50), (0, "Результаты исследований и", 94), (2, ":", 0), (4, "--", 0), (5, ":", 0))), + cells_by_index(_row((1, "1130", 80), (0, "Нематериальные поисковые", 87), (2, "не ****", 5), (4, "3", 0), (5, "+ :", 0))), + cells_by_index(_row((1, "1150", 100), (0, "Основные средства", 100), (2, "1 234", 100), (4, "5 678", 100), (5, "", 100))), + ] + layout = infer_layout(rows, KNOWN) + assert layout is not None + assert layout.code_idx == 1 + assert layout.label_idx == 0 + assert 2 in layout.value_idxs and 5 in layout.value_idxs # суммы не реже мусора / столбец пуст + + def test_no_known_codes_is_none(self) -> None: + rows = [cells_by_index(_row((0, "текст", 100), (1, "мусор", 100)))] + assert infer_layout(rows, KNOWN) is None + + def test_empty_rows_is_none(self) -> None: + assert infer_layout([], KNOWN) is None +``` + +- [ ] **Step 2: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/unit/normalizer/test_layout.py -q`. + +- [ ] **Step 3: Реализация** — `api/src/normalizer/ocr/layout.py`: + +```python +"""Раскладка колонок таблицы OCR (§11.1 п. 3). Перенос `_build_layout`, `_infer_layout`, `_cells_by_index`, +`_cell_value` старого ядра: справочник кодов приходит параметром, ячейки — `Cell` вместо словарей.""" + +from collections.abc import Collection, Mapping, Sequence +from dataclasses import dataclass + +from src.normalizer.ocr.amounts import is_blank, parse_amount +from src.normalizer.ocr.text import is_code_header, is_value_header, normalize_header + +__all__ = ["Cell", "Cells", "ColumnLayout", "build_layout", "cell_text", "cells_by_index", "infer_layout"] + + +@dataclass(frozen=True) +class Cell: + value: str + confidence: float + + +Cells = dict[int, Cell] + + +@dataclass(frozen=True) +class ColumnLayout: + """Индексы значимых столбцов таблицы OCR: из строки заголовка или выведенные по строкам данных.""" + + label_idx: int | None + code_idx: int + value_idxs: tuple[int, ...] + + +def cells_by_index(row: Mapping[str, object]) -> Cells: + cells: Cells = {} + raw_cells = row.get("cells") + if not isinstance(raw_cells, list): + return cells + for raw in raw_cells: + if not isinstance(raw, Mapping) or not isinstance(raw.get("column_index"), int): + continue + confidence = raw.get("confidence") + cells[int(raw["column_index"])] = Cell( + str(raw.get("value") or ""), float(confidence) if isinstance(confidence, int | float) else 0.0 + ) + return cells + + +def cell_text(cells: Cells, idx: int | None) -> str: + if idx is None or idx not in cells: + return "" + return cells[idx].value + + +def build_layout(cells: Cells) -> ColumnLayout | None: + """Раскладка, если строка является заголовком (есть ячейка «Код…»): наименование — ячейка с «наименование», + иначе последняя заполненная левее кода; значения — правее кода, где год или «на »/«за ».""" + headers = {idx: normalize_header(cells[idx].value) for idx in sorted(cells)} + code_idx = next((idx for idx, text in headers.items() if is_code_header(text)), None) + if code_idx is None: + return None + label_idx = next((idx for idx, text in headers.items() if "наименование" in text), None) + if label_idx is None: + left_filled = [idx for idx, text in headers.items() if idx < code_idx and text] + label_idx = left_filled[-1] if left_filled else None + value_idxs = tuple(idx for idx, text in headers.items() if idx > code_idx and is_value_header(text)) + return ColumnLayout(label_idx=label_idx, code_idx=code_idx, value_idxs=value_idxs) + + +def infer_layout(rows_cells: Sequence[Cells], known_codes: Collection[str]) -> ColumnLayout | None: + """Раскладка по строкам данных, когда заголовка нет: код — столбец с наибольшим числом известных кодов; наименование — + левее кода с наибольшим числом текстовых ячеек; значения — правее кода, где суммы не реже мусора или столбец пуст. + Столбец оценивается по всем строкам сразу, поэтому пустые ячейки отдельных строк не влияют.""" + columns: dict[int, list[str]] = {} + for cells in rows_cells: + for idx, cell in cells.items(): + columns.setdefault(idx, []).append(cell.value.strip()) + code_hits = {idx: sum(1 for v in values if v in known_codes) for idx, values in columns.items()} + code_idx = min(code_hits, key=lambda idx: (-code_hits[idx], idx), default=None) + if code_idx is None or code_hits[code_idx] == 0: + return None + text_hits = { + idx: sum(1 for v in values if any(c.isalpha() for c in v)) for idx, values in columns.items() if idx < code_idx + } + label_idx = min(text_hits, key=lambda idx: (-text_hits[idx], -idx), default=None) + if label_idx is not None and text_hits[label_idx] == 0: + label_idx = None + value_idxs = [] + for idx in sorted(columns): + if idx <= code_idx: + continue + filled = [v for v in columns[idx] if not is_blank(v)] + amounts = sum(1 for v in filled if parse_amount(v).amount is not None) + if amounts >= len(filled) - amounts: + value_idxs.append(idx) + return ColumnLayout(label_idx=label_idx, code_idx=code_idx, value_idxs=tuple(value_idxs)) +``` + +- [ ] **Step 4: Тесты и линт** — PASS. В `test_aep3_like_rows_without_header` столбец 4 («| | г», «--», «3», «5 678»): заполнено 3 (`--` — прочерк), суммы 2 (`3`, `5 678`) ≥ 1 → тоже значение; тест этого не требует, но и не запрещает. + +- [ ] **Step 5: Commit** + +```bash +git add api/src/normalizer/ocr/layout.py api/tests/unit/normalizer/test_layout.py +git commit -m "feat(normalizer): раскладка колонок OCR — шапка «Код» и вывод по данным (§11.1 п. 3)" +``` + +--- + +### Task 6: Строки таблицы (`src/normalizer/ocr/rows.py`) + +**Files:** +- Create: `api/src/normalizer/ocr/rows.py` +- Test: `api/tests/unit/normalizer/test_rows.py` + +**Interfaces:** +- Consumes: `Cells`, `ColumnLayout`, `build_layout`, `infer_layout`, `cells_by_index`, `cell_text` (Task 5); `clean_value`, `normalize_header` (Task 2). +- Produces: + - `class RowKind(StrEnum)`: `HEADER`, `ROW`, `TOTAL`, `BALANCE`. + - `@dataclass(frozen=True) ParsedRow(cells: Cells, layout: ColumnLayout, kind: RowKind, ordinal: int)`. + - `classify_row(cells: Cells, layout: ColumnLayout) -> RowKind | None` (перенос `_classify_row`: пустой код → `None`; + нечисловой код → `HEADER`; наименование «Баланс» → `BALANCE`; начинается с «итого» → `TOTAL`; иначе `ROW`). + - `@dataclass(frozen=True) ParsedTable(rows: list[ParsedRow], synthetic_header: bool)` — `rows[0]` всегда шапка. + - `parse_table_rows(table_rows: Sequence[Mapping[str, object]], known_codes: Collection[str]) -> ParsedTable | None` + (перенос `_parse_table_rows`; `None` — ни шапки, ни столбца кода). + - `table_type_by_codes(parsed: ParsedTable) -> Literal["BALANCE", "INCOME"] | None` (перенос `_resolve_table_type`: + больше кодов на `1` → BALANCE, на `2` → INCOME, равенство/нет кодов → `None`; `KeyError`/`IndexError` источника → `None`). + +- [ ] **Step 1: Падающие тесты** — `api/tests/unit/normalizer/test_rows.py`: + +```python +"""Строки таблицы OCR (§11.1 п. 3–5): шапка настоящая/повторная/синтетическая, классы строк, тип по кодам.""" + +import json +from pathlib import Path + +import pytest + +from src.normalizer.ocr.layout import ColumnLayout, cells_by_index +from src.normalizer.ocr.rows import ParsedTable, RowKind, classify_row, parse_table_rows, table_type_by_codes + +API_DIR = Path(__file__).resolve().parents[3] +REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr" +KNOWN = frozenset({"1105", "1110", "1120", "1130", "1150", "1600", "2110", "2120", "2100"}) + + +def _row(index: int, *cells: tuple[int, str, float]) -> dict: + return {"row_index": index, "cells": [{"column_index": i, "value": v, "confidence": c} for i, v, c in cells]} + + +HEADER = _row(0, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "На 30 июня\n2025 г", 100), (4, "На 31 декабря\n2024 г.", 100)) + + +def _real_table(name: str, index: int) -> list[dict]: + if not REAL_FIXTURES.is_dir(): + pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") + data = json.loads((REAL_FIXTURES / name).read_text(encoding="utf-8")) + return list(data["data_ocr"]["table_structure"][index]["rows"]) + + +class TestClassifyRow: + layout = ColumnLayout(label_idx=0, code_idx=1, value_idxs=(2,)) + + @pytest.mark.parametrize( + ("label", "code", "expected"), + [ + ("Выручка", "2110", RowKind.ROW), + ("Итого по разделу I", "1100", RowKind.TOTAL), + ("Баланс", "1600", RowKind.BALANCE), + ("Наименование", "Код", RowKind.HEADER), + ("в том числе:", "", None), + ], + ) + def test_kinds(self, label: str, code: str, expected: RowKind | None) -> None: + cells = cells_by_index(_row(1, (0, label, 100), (1, code, 100), (2, "1", 100))) + assert classify_row(cells, self.layout) is expected + + +class TestParseTableRows: + def test_header_then_rows(self) -> None: + rows = [HEADER, _row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "13 070", 100), (4, "", 100)), + _row(2, (0, "в том числе:", 93), (1, "", 100), (2, "13 070", 100), (4, "", 100))] + parsed = parse_table_rows(rows, KNOWN) + assert isinstance(parsed, ParsedTable) and not parsed.synthetic_header + assert [r.kind for r in parsed.rows] == [RowKind.HEADER, RowKind.ROW] + assert parsed.rows[0].layout == ColumnLayout(label_idx=0, code_idx=1, value_idxs=(2, 4)) + assert parsed.rows[1].ordinal == 1 + + def test_second_header_overrides_layout(self) -> None: + """Review Focus 1: после повторной шапки с другим набором колонок строки идут по новой раскладке.""" + rows = [ + HEADER, + _row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "1", 100), (4, "2", 100)), + _row(2, (0, "Наименование показателя", 100), (1, "Код", 100), (3, "За 2024 г.", 100)), + _row(3, (0, "Себестоимость", 100), (1, "2120", 100), (3, "(5)", 100)), + ] + parsed = parse_table_rows(rows, KNOWN) + assert parsed is not None + kinds = [r.kind for r in parsed.rows] + assert kinds == [RowKind.HEADER, RowKind.ROW, RowKind.ROW] # повторная шапка не становится строкой + assert parsed.rows[1].layout.value_idxs == (2, 4) + assert parsed.rows[2].layout.value_idxs == (3,) + + def test_rows_before_header_use_inferred_layout(self) -> None: + rows = [ + _row(0, (1, "1110", 100), (0, "Нематериальные активы", 100), (2, "5", 100)), + _row(1, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "2024", 100)), + _row(2, (0, "Основные средства", 100), (1, "1150", 100), (2, "7", 100)), + ] + parsed = parse_table_rows(rows, KNOWN) + assert parsed is not None and not parsed.synthetic_header + assert [r.ordinal for r in parsed.rows] == [-1, 0, 2] # шапка встаёт раньше первой строки данных + assert parsed.rows[0].kind is RowKind.HEADER + + def test_synthetic_header_when_none(self) -> None: + rows = [_row(0, (1, "1110", 100), (0, "Нематериальные активы", 100), (2, "5", 100)), + _row(1, (1, "1150", 100), (0, "Основные средства", 100), (2, "7", 100))] + parsed = parse_table_rows(rows, KNOWN) + assert parsed is not None and parsed.synthetic_header + header = parsed.rows[0] + assert header.kind is RowKind.HEADER and header.ordinal == -1 + assert header.cells[1].value == "Код" and header.cells[0].value == "Наименование показателя" + assert header.layout.value_idxs == (2,) and header.cells.get(2) is None # тексты периодов пустые + + def test_no_header_no_codes_is_none(self) -> None: + assert parse_table_rows([_row(0, (0, "текст", 100), (1, "мусор", 100))], KNOWN) is None + assert parse_table_rows([], KNOWN) is None + + @pytest.mark.fixtures + def test_aep3_balance_without_header(self) -> None: + parsed = parse_table_rows(_real_table("aep_3.json", 0), KNOWN) + assert parsed is not None and parsed.synthetic_header + assert parsed.rows[0].layout.code_idx == 1 + assert any(r.cells[1].value.strip() == "1120" for r in parsed.rows[1:]) + + @pytest.mark.fixtures + def test_aep6_balance_with_header(self) -> None: + parsed = parse_table_rows(_real_table("aep_6.json", 0), KNOWN) + assert parsed is not None and not parsed.synthetic_header + assert parsed.rows[0].layout == ColumnLayout(label_idx=0, code_idx=1, value_idxs=(2, 4, 5)) + assert table_type_by_codes(parsed) == "BALANCE" + + +class TestTableType: + def test_by_first_digit(self) -> None: + rows = [HEADER, _row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "1", 100)), + _row(2, (0, "Себестоимость", 100), (1, "2120", 100), (2, "1", 100)), + _row(3, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100))] + assert table_type_by_codes(parse_table_rows(rows, KNOWN)) == "INCOME" + + def test_tie_or_empty_is_none(self) -> None: + rows = [HEADER, _row(1, (0, "a", 100), (1, "2110", 100), (2, "1", 100)), _row(2, (0, "b", 100), (1, "1150", 100), (2, "1", 100))] + assert table_type_by_codes(parse_table_rows(rows, KNOWN)) is None + assert table_type_by_codes(ParsedTable(rows=[], synthetic_header=False)) is None +``` + +- [ ] **Step 2: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/unit/normalizer/test_rows.py -q`. + +- [ ] **Step 3: Реализация** — `api/src/normalizer/ocr/rows.py`: + +```python +"""Строки таблицы OCR (§11.1 п. 3–5). Перенос `_classify_row`, `_parse_table_rows`, `_synthetic_header_cells`, +`_resolve_table_type` старого ядра: шапка — строка с «Код» (повторная перезаписывает раскладку), без шапки раскладка +выводится по данным и шапка синтезируется; строки до первой шапки — по выведенной раскладке.""" + +from collections.abc import Collection, Mapping, Sequence +from dataclasses import dataclass +from enum import StrEnum +from typing import Literal + +from src.normalizer.ocr.layout import Cell, Cells, ColumnLayout, build_layout, cell_text, cells_by_index, infer_layout + +__all__ = ["ParsedRow", "ParsedTable", "RowKind", "classify_row", "parse_table_rows", "table_type_by_codes"] + +HEADER_CODE = "Код" +HEADER_LABEL = "Наименование показателя" +FULL_CONFIDENCE = 100.0 + + +class RowKind(StrEnum): + HEADER = "HEADER" + ROW = "ROW" + TOTAL = "TOTAL" + BALANCE = "BALANCE" + + +@dataclass(frozen=True) +class ParsedRow: + cells: Cells + layout: ColumnLayout + kind: RowKind + ordinal: int + + +@dataclass(frozen=True) +class ParsedTable: + """Разобранные строки: `rows[0]` — шапка (настоящая или синтетическая), дальше строки данных по порядку.""" + + rows: list[ParsedRow] + synthetic_header: bool + + +def classify_row(cells: Cells, layout: ColumnLayout) -> RowKind | None: + code = cell_text(cells, layout.code_idx).strip() + if not code: + return None + try: + int(code) + except (TypeError, ValueError): + return RowKind.HEADER + label = cell_text(cells, layout.label_idx) + if label == "Баланс": + return RowKind.BALANCE + if label.lower().startswith("итого"): + return RowKind.TOTAL + return RowKind.ROW + + +def _data_row(cells: Cells, layout: ColumnLayout, ordinal: int) -> ParsedRow | None: + kind = classify_row(cells, layout) + if kind is None or kind is RowKind.HEADER: + return None + return ParsedRow(cells, layout, kind, ordinal) + + +def _synthetic_header_cells(layout: ColumnLayout) -> Cells: + """Ячейки восстановленного заголовка: код и наименование, тексты периодов пустые (ячеек под значениями нет).""" + cells: Cells = {layout.code_idx: Cell(HEADER_CODE, FULL_CONFIDENCE)} + if layout.label_idx is not None: + cells[layout.label_idx] = Cell(HEADER_LABEL, FULL_CONFIDENCE) + return cells + + +def _ordinal(row: Mapping[str, object], fallback: int) -> int: + index = row.get("row_index") + return index if isinstance(index, int) else fallback + + +def parse_table_rows(table_rows: Sequence[Mapping[str, object]], known_codes: Collection[str]) -> ParsedTable | None: + rows_cells = [(_ordinal(row, position), cells_by_index(row)) for position, row in enumerate(table_rows)] + first_header = next( + (pos for pos, (_, cells) in enumerate(rows_cells) if build_layout(cells) is not None), len(rows_cells) + ) + before_header = rows_cells[:first_header] + inferred = infer_layout([cells for _, cells in before_header], known_codes) if before_header else None + leading_rows: list[ParsedRow] = [] + if inferred is not None: + for ordinal, cells in before_header: + parsed = _data_row(cells, inferred, ordinal) + if parsed is not None: + leading_rows.append(parsed) + + if first_header == len(rows_cells): + if not leading_rows or inferred is None: + return None + header_ordinal = min(p.ordinal for p in leading_rows) - 1 + header = ParsedRow(_synthetic_header_cells(inferred), inferred, RowKind.HEADER, header_ordinal) + return ParsedTable([header, *leading_rows], synthetic_header=True) + + header_row_index, header_cells = rows_cells[first_header] + layout = build_layout(header_cells) + if layout is None: # исключено выбором first_header + return None + header_ordinal = min([header_row_index, *(p.ordinal - 1 for p in leading_rows)]) + parsed_rows = [ParsedRow(header_cells, layout, RowKind.HEADER, header_ordinal), *leading_rows] + for ordinal, cells in rows_cells[first_header + 1 :]: + header_layout = build_layout(cells) + if header_layout is not None: + layout = header_layout + continue + parsed = _data_row(cells, layout, ordinal) + if parsed is not None: + parsed_rows.append(parsed) + return ParsedTable(parsed_rows, synthetic_header=False) + + +def table_type_by_codes(parsed: ParsedTable) -> Literal["BALANCE", "INCOME"] | None: + """Тип таблицы по первой цифре кодов строк данных: больше `1xxx` → BALANCE, больше `2xxx` → INCOME.""" + counts = {"1": 0, "2": 0} + for row in parsed.rows: + if row.kind is RowKind.HEADER: + continue + code = cell_text(row.cells, row.layout.code_idx).strip() + if code: + counts[code[0]] = counts.get(code[0], 0) + 1 + if counts["1"] > counts["2"]: + return "BALANCE" + if counts["2"] > counts["1"]: + return "INCOME" + return None +``` + +Отличия от источника (фиксируются в отчёте задачи): `row_index` не-int → позиция в списке; `_resolve_table_type` +бросал `ValueError` при `KeyError` — здесь пустой код пропускается, равенство → `None` (источник возвращал `None` неявно). + +- [ ] **Step 4: Тесты и линт** — `cd api && .venv/bin/pytest tests/unit/normalizer/test_rows.py -q && cd .. && make lint` → PASS +(тесты с маркером `fixtures` требуют `../fixtures/ocr`; в worktree это симлинк). + +- [ ] **Step 5: Commit** + +```bash +git add api/src/normalizer/ocr/rows.py api/tests/unit/normalizer/test_rows.py +git commit -m "feat(normalizer): строки таблицы OCR — шапки, классы строк, тип по кодам (§11.1 п. 3–5)" +``` + +--- + +### Task 7: Шапка файла (`src/normalizer/ocr/header.py`) + +**Files:** +- Create: `api/src/normalizer/ocr/header.py` +- Test: `api/tests/unit/normalizer/test_header.py` + +**Interfaces:** +- Consumes: `fix_lookalikes`, `digits_only`, `clean_value`, `collapse_whitespace` (Task 2); `parse_date` (Task 3); `OcrRules.lookalikes` (Task 1). +- Produces: + - `@dataclass(frozen=True) FileHeader(organization_name: str | None, organization_confidence: float | None, + inn: str | None, inn_confidence: float | None, units: str | None, report_date_text: str | None, + report_date: date | None, report_date_recognized: bool)`. + - `parse_file_header(data_ocr: Mapping[str, object], lookalikes: Mapping[str, str]) -> FileHeader` — §11.1 п. 8: + `inn = digits_only(form_name)` или `None`, `inn_confidence = confidence`, если сырой текст после `strip` состоял только из + цифр, иначе `min(confidence, 99)`; `organization_name = collapse_whitespace(form_name)` или `None`, `organization_confidence = confidence`; + `units = clean_value(form_name)` или `None`; `report_date_text = form_name` как есть (`None`, если пусто), + `report_date = parse_date(fix_lookalikes(form_name))`, `report_date_recognized = confidence == 100 and report_date is not None`. + Поле отсутствует/не объект → все его значения `None`/`0`. + +- [ ] **Step 1: Падающие тесты** — `api/tests/unit/normalizer/test_header.py`: + +```python +"""Шапка документа из `data_ocr` (§11.1 п. 8) на реальных `aep_3.json` (мусор) и `aep_6.json` (чисто).""" + +import json +from datetime import date +from pathlib import Path + +import pytest + +from src.normalizer.ocr.header import FileHeader, parse_file_header + +API_DIR = Path(__file__).resolve().parents[3] +REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr" +LOOKALIKES = {"О": "0", "о": "0", "З": "3", "l": "1", "I": "1", "|": "1", "б": "6", "S": "5", "Ч": "4"} + + +def _real(name: str) -> dict: + if not REAL_FIXTURES.is_dir(): + pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") + return dict(json.loads((REAL_FIXTURES / name).read_text(encoding="utf-8"))["data_ocr"]) + + +def _field(text: str, confidence: float) -> dict: + return {"form_name": text, "confidence": confidence} + + +def test_clean_header() -> None: + header = parse_file_header( + {"organization": _field("ООО Ромашка", 97.5), "inn": _field("7700000000", 100), "units": _field("тыс. руб.", 100), + "date": _field("на 31 декабря 2024 г.", 100)}, + LOOKALIKES, + ) + assert header == FileHeader("ООО Ромашка", 97.5, "7700000000", 100.0, "тыс. руб.", "на 31 декабря 2024 г.", + date(2024, 12, 31), True) + + +def test_noisy_inn_caps_confidence() -> None: + header = parse_file_header({"inn": _field("3|1 21 2| о! О' 0 0; 3: 51", 100)}, LOOKALIKES) + assert header.inn == "3121200351" + assert header.inn_confidence == 99.0 + assert header.organization_name is None and header.organization_confidence is None + + +def test_missing_fields() -> None: + header = parse_file_header({}, LOOKALIKES) + assert header == FileHeader(None, None, None, None, None, None, None, False) + + +@pytest.mark.fixtures +def test_aep6_header() -> None: + header = parse_file_header(_real("aep_6.json"), LOOKALIKES) + assert header.inn is None # текст «+» + assert header.units == "тыс. руб." + assert header.report_date is None # в поле date — название организации + assert header.report_date_text is not None and header.report_date_recognized is False + assert header.organization_name is not None and "Дорстроймонтаж" in header.organization_name + + +@pytest.mark.fixtures +def test_aep3_header() -> None: + header = parse_file_header(_real("aep_3.json"), LOOKALIKES) + assert header.inn == "3121200351" and header.inn_confidence is not None and header.inn_confidence < 100 + assert header.report_date == date(2024, 12, 31) + assert header.report_date_recognized is False # confidence 75 +``` + +- [ ] **Step 2: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/unit/normalizer/test_header.py -q`. + +- [ ] **Step 3: Реализация** — `api/src/normalizer/ocr/header.py`: + +```python +"""Шапка документа из полей `data_ocr` (§11.1 п. 8): ИНН — только цифры, единицы — очищенный текст, дата отчёта — +`parse_date` после замены двойников; «распознано» — `confidence == 100` и сырой текст без мусора.""" + +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import date + +from src.normalizer.ocr.dates import parse_date +from src.normalizer.ocr.text import clean_value, collapse_whitespace, digits_only, fix_lookalikes + +__all__ = ["FileHeader", "parse_file_header"] + +FULL_CONFIDENCE = 100.0 +CAPPED_CONFIDENCE = 99.0 + + +@dataclass(frozen=True) +class FileHeader: + organization_name: str | None + organization_confidence: float | None + inn: str | None + inn_confidence: float | None + units: str | None + report_date_text: str | None + report_date: date | None + report_date_recognized: bool + + +def _field(data_ocr: Mapping[str, object], name: str) -> tuple[str, float | None]: + raw = data_ocr.get(name) + if not isinstance(raw, Mapping): + return "", None + text = raw.get("form_name") + confidence = raw.get("confidence") + return (str(text) if isinstance(text, str) else "", float(confidence) if isinstance(confidence, int | float) else None) + + +def parse_file_header(data_ocr: Mapping[str, object], lookalikes: Mapping[str, str]) -> FileHeader: + organization, organization_confidence = _field(data_ocr, "organization") + inn_raw, inn_confidence = _field(data_ocr, "inn") + units_raw, _ = _field(data_ocr, "units") + date_raw, date_confidence = _field(data_ocr, "date") + + organization_name = collapse_whitespace(organization) or None + inn = digits_only(inn_raw) or None + if inn is None: + inn_confidence = None + elif inn_confidence is not None and inn_raw.strip() != inn: + inn_confidence = min(inn_confidence, CAPPED_CONFIDENCE) + report_date = parse_date(fix_lookalikes(date_raw, lookalikes)) if date_raw.strip() else None + return FileHeader( + organization_name=organization_name, + organization_confidence=organization_confidence if organization_name is not None else None, + inn=inn, + inn_confidence=inn_confidence, + units=clean_value(units_raw) or None, + report_date_text=date_raw.strip() or None, + report_date=report_date, + report_date_recognized=date_confidence == FULL_CONFIDENCE and report_date is not None, + ) +``` + +- [ ] **Step 4: Тесты и линт** — PASS. + +- [ ] **Step 5: Commit** + +```bash +git add api/src/normalizer/ocr/header.py api/tests/unit/normalizer/test_header.py +git commit -m "feat(normalizer): шапка документа из data_ocr — ИНН, единицы, дата отчёта (§11.1 п. 8)" +``` + +--- + +### Task 8: Сборка документа (`src/normalizer/ocr/document.py`) + +**Files:** +- Create: `api/src/normalizer/ocr/document.py` +- Test: `api/tests/unit/normalizer/test_document.py` + +**Interfaces:** +- Consumes: `OcrRules`, `Edition` (Task 1); `clean_value`, `collapse_whitespace`, `fix_lookalikes`, `normalize_header` (Task 2); + `parse_date` (Task 3); `parse_amount` (Task 4); `cell_text` (Task 5); `ParsedTable`, `RowKind`, `parse_table_rows`, + `table_type_by_codes` (Task 6); `FileHeader` (Task 7); модели `StatementDocument`, `StatementHeader`, `StatementColumn`, + `StatementLine`, `StatementValue` из `src.contract.result`. +- Produces: + - `@dataclass(frozen=True) BuiltDocument(document: StatementDocument, warnings: list[str], fully_recognized: bool, edition_id: str | None)`. + - `build_document(table: Mapping[str, object], index: int, file_name: str, header: FileHeader, rules: OcrRules) -> BuiltDocument | None` + — `None`, если таблица не разобрана (ни шапки, ни кодов); предупреждения без префикса файла/таблицы (их добавляет + `OcrNormalizer`): `"нет шапки и столбца кода"` (возвращается через `None`, вызывающий пишет сам), `"шапка синтезирована: колонки без дат"`, + `"тип по кодам (<тип>) не совпал с label (