169 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 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)` пишет `<Tmp>/.<job_id>.<ext>.part`, затем rename без замены в `<Input>/<job_id>.<ext>`.
Расширение — суффикс `original_name` запроса в нижнем регистре; без суффикса — `RECOGNITION_FAILED` (ИС различает
форматы по расширению). Назначение уже существует (повторная публикация задания после потери ack) → файл считается
доставленным, `.part` остаётся до очистки. Владелец отклонил `.tmp` рядом с назначением (как у FileWorker): ИС может
подхватить недописанный файл; FileWorker полагается на игнор `*.tmp`, у нас такой гарантии нет. `poll` без изменений.
### D-2. Очистка — отдельный метод под флагом `SCAN_CLEANUP` (решение владельца)
`cleanup(job_id, ext)` удаляет `Output/<job_id>/*.json`, каталог `Output/<job_id>`, `<Input>/<job_id>.<ext>`,
`<Input>/<job_id>.<ext>.done` (след эмулятора) и `<Tmp>/.<job_id>.<ext>.part`. Вызывается воркером только после того,
как набор ответа прочитан и склеен в `raw/` (любой исход разбора), и только при `SCAN_CLEANUP=true`. При `TIMEOUT`,
ошибке чтения S3, ошибке записи в горячую папку — не вызывается. Любое удаление, включая `.part`, подчиняется флагу: на
SMB-шаре контура права на удаление не подтверждены. Ошибки удаления — одна строка лога на задание, исход задания и
`/healthcheck` не меняются. По умолчанию `false`; dev-compose — `true`.
### D-3. Эмулятор ищет пару по sha256 содержимого, при промахе — набор по умолчанию (решение владельца, вариант 3)
Имя входящего файла больше не несёт имени фикстуры. Эмулятор при старте индексирует `SCAN_FIXTURES_DIR`: для каждого
файла, не являющегося `*.json`, считается sha256 → основа имени `<name>` (без расширения); индекс пересчитывается,
если изменился список файлов каталога. Для входящего `<uuid>.<ext>` считается sha256; совпадение → набор `<name>.json` +
`<name>_*.json` по прежним правилам (`<uuid>.json`, `<uuid>_<suffix>.json`, `file_uid = <uuid>`); промах →
`SCAN_EMULATOR_DEFAULT=<name>` (если задан и его набор существует) → иначе ответ-ошибка `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:<i>: <текст>` — таблица пропущена (нет шапки и столбца кода), шапка
синтезирована (колонки без дат), тип по кодам разошёлся с `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 = min(confidence ячейки кода, 99)` (решение №19).
- `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/<name>.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` — данные владельца.
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
| № | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| 1 | 2026-10-06 | Один `statementDocument` на каждую таблицу `table_structure[i]`; `locator = "file:<имя json>;table:<i>"` | §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/` и подсветку |
| 13 | 2026-10-06 | В переносе `parse_amount` признак `verified` считается до постановки знака минус (в источнике — после, и любая сумма в скобках получала `verified = False`) | иначе каждая отрицательная сумма формы давала `is_recognized = false` и `needs_check` без причины; единственное отличие от источника, зафиксировано тестом `(6032409) → -6032409, verified` | расхождение с поведением старого ядра на стенде: там такие суммы подсвечивались, у нас — нет |
| 14 | 2026-10-06 | `CONTRACT_VERSION` scan-gateway остаётся `1.0`, хотя копия схемы — v1.2 | документы 3a не используют полей 1.1–1.2 (`DISCLOSURE`, `checks`, границы периода ОСВ); потребитель принимает любой MINOR; меньше изменений в тестах конверта | если ядро начнёт различать MINOR производителя — одна константа и проверка в `worker_helpers` |
| 15 | 2026-10-06 | Повтор кода строки в таблице OCR: остаётся первая строка, предупреждение, `needs_check` | DBML: уникальный индекс `financial_statement_line (meta_id, line_code)` — без этого маппинг 3b падает; Excel-путь 2b делает так же (§12 п. 5); старое ядро дубли не убирало | вторая строка с тем же кодом теряется в документе (остаётся в `raw/`) |
| 16 | 2026-10-06 | `string_value` заполняется только при `amount = null` и непустом не-прочерке, текстом как распознан (`\n`/`\t` → пробел); у разобранных сумм — `null` | DBML `financial_statement_value.string_value`: «как распознано, если не число»; так же в 2b; первичная редакция спеки и старое ядро писали очищенный текст всегда | аналитик не видит сырой текст суммы на экране (он в `raw/`) |
| 11 | 2026-10-06 | Имя строки: из словаря редакции по коду; из OCR — только если кода нет в словаре | старое ядро, §11.1 п. 6 | расхождение имени словаря и формы клиента не видно аналитику (видно в `raw/`) |
| 17 | 2026-10-07 | Запись во входящий каталог «без замены» реализуется как `os.link(.part → назначение)` + `unlink(.part)` (локальная ФС), с запасным путём `os.replace` при `EPERM/EXDEV/ENOTSUP`, если назначения нет; на SMB — `smbclient.rename` | POSIX `rename` перезаписывает; жёсткая ссылка атомарно даёт `FileExistsError`; в D-1 сказано «rename без замены» — это его реализация | на ФС без жёстких ссылок окно гонки (проверка существования и `replace`) — повтор задания с тем же `job_id` редок |
| 18 | 2026-10-07 | R-1. Тест плана `test_aep3_like_rows_without_header` требовал `5 in layout.value_idxs`; ожидание заменено на `layout.value_idxs == (2, 4)`, алгоритм `infer_layout` не менялся | столбец 5 содержит только мусор («:», «+ :») и пустые ячейки: по дословному `_infer_layout` суммы (0) < мусор (2) — столбец не значение; ошибка плана, а не переноса | если реальный `aep_3.json` даст иной набор колонок — выяснится в golden (Task 10) |
| 19 | 2026-10-07 | R-2. `line_code_confidence = confidence ячейки кода` только если очищенный текст равен сырому И код есть в выбранной редакции; иначе `min(confidence, 99)`; `line_name_confidence` словарного имени равна `line_code_confidence`, имени из OCR — `confidence` ячейки наименования | требование «Нормализация ответа ИС» спеки; код плана капил уверенность только при мусоре в тексте кода, тест плана ожидал 99 для кода вне справочника | код вне справочника чаще даёт `needs_check` — это то, что нужно аналитику |
| 20 | 2026-10-07 | О-3 (наблюдение, не меняет поведение): прочерк «—»/«–» при `confidence = 100` даёт `is_recognized = false` (дословный `parse_amount` вырезает тире → `verified = false`), дефис «-» — `true` | дословный перенос и формулировка спеки «разбор не отбрасывал символов» | лишний `needs_check` на формах с длинным тире; правило уточняется в §11.1 после проверки на стенде |
| 21 | 2026-10-07 | R-3. `values` строки строятся по позиции колонки документа; при ином числе колонок у раскладки строки после повторной шапки — недостающие как «ячейки нет», лишние отбрасываются, одно предупреждение на таблицу, `needs_check` | спека: «values — по одному на колонку»; код плана давал значения по индексам раскладки строки и мог породить `column_index`-сироту, на которой упадёт уникальность `(meta_id, column_index)` в 3b | значения «лишних» колонок повторной шапки теряются в документе (остаются в `raw/`) |
| 22 | 2026-10-07 | R-4. `fix_lookalikes` — только для дат (дата отчёта, подписи колонок); коды — `clean_value`, суммы — `parse_amount`, ИНН — `digits_only`. Формулировка спеки про коды и суммы исправлена | §11.1 п. 1 и проверенный на стенде старый код; замена `S→5` и т. п. в суммах не проверена на стенде | код с латинской `O` вместо нуля отбрасывается как не-целое (остаётся в `raw/`) |
| 23 | 2026-10-07 | R-5. Предупреждения «код N из редакции X» не выдаются, если выбранная редакция пуста | до заполнения `fsbu4` каждая строка 2025 года давала бы шумное предупреждение (на `aep_6` 39+25), информативно одно «редакция <id> не заполнена» | при пустой редакции аналитик не видит, что код из другой редакции |
| 24 | 2026-10-07 | `header.form_name` не передаётся, если у таблицы нет `label` | контракт запрещает `null` в `form_name` (`OptionalNotNull`) | поле отсутствует вместо `null` |
| 25 | 2026-10-07 | R-6. Таблица без колонок значений (`columns = []`) → предупреждение «нет колонок значений», `needs_check` | иначе документ без колонок мог оказаться `done` | — |
| 26 | 2026-10-07 | R-8. `.part`, ставший целью успешной публикации (`os.link` → назначение), снимается самой записью независимо от `SCAN_CLEANUP`; ошибка его снятия подавляется. Флаг управляет остатками: осиротевшие `.part`, `.part` при уже существующем назначении, ответы, входящий файл, `.done` | иначе при `SCAN_CLEANUP=false` на каждое задание оставалась бы жёсткая ссылка; ошибка удаления на шаре с неподтверждёнными правами не должна превращать доставленный файл в `RECOGNITION_FAILED` | на ФС без права удаления в `Tmp` у каждого задания останется `.part`-ссылка (место не занимает) |