# 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 = 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/.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:"` | §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), информативно одно «редакция не заполнена» | при пустой редакции аналитик не видит, что код из другой редакции | | 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`-ссылка (место не занимает) | | 27 | 2026-10-07 | R-9. При `SCAN_CLEANUP=false` сервис ничего не удаляет и не логирует «осиротевший `.part`»; сценарий «Очистка выключена» приведён к «все файлы остаются на месте» | в слое горячих папок нет логгера, а поиск сирот потребовал бы обхода `Tmp` на каждое задание; ценность — только диагностическая | сироты в `Tmp` при выключенной очистке накапливаются без заметки в логе; чистятся вручную или включением флага |