Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
20 KiB
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
- Права на удаление на SMB-шаре контура (новый OQ) — пока
SCAN_CLEANUP=false. - Нужна ли ядру редакция формы в конверте (кандидат в MINOR 1.3) — новый OQ.
- Словари §7 и §12 по редакциям (2b, 3b) — новый OQ, расширение OQ-12.
- Состав редакции
fsbu4— данные владельца. - Скобки, распознанные как
}/]((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/ и подсветку |
| 11 | 2026-10-06 | Имя строки: из словаря редакции по коду; из OCR — только если кода нет в словаре | старое ядро, §11.1 п. 6 | расхождение имени словаря и формы клиента не видно аналитику (видно в raw/) |