28 KiB
Raw Blame History

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), информативно одно «редакция не заполнена» при пустой редакции аналитик не видит, что код из другой редакции
24 2026-10-07 header.form_name не передаётся, если у таблицы нет label контракт запрещает null в form_name (OptionalNotNull) поле отсутствует вместо null
25 2026-10-07 R-6. Таблица без колонок значений (columns = []) → предупреждение «нет колонок значений», needs_check иначе документ без колонок мог оказаться done —