Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
156 lines
21 KiB
Markdown
156 lines
21 KiB
Markdown
# 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` из 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/<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` | расхождение с поведением старого ядра на стенде: там такие суммы подсвечивались, у нас — нет |
|
||
| 11 | 2026-10-06 | Имя строки: из словаря редакции по коду; из OCR — только если кода нет в словаре | старое ядро, §11.1 п. 6 | расхождение имени словаря и формы клиента не видно аналитику (видно в `raw/`) |
|