2026-10-17 11:48:10 +04:00

13 KiB
Raw Blame History

Единый алгоритм нормализации ББ/ОФР (scan-gateway и tabular-parser) — дизайн

Issue: arm-ka-docs#116. Статус: черновик, идёт разбор текущего процесса по шагам (brainstorming 17.10.2026).

Цель

Нормализация результата распознавания форм ББ/ОФР в scan-gateway (PDF → ИС Сканирование) и в tabular-parser (Excel) должна идти по одному алгоритму и отдавать единообразный результат, чтобы arm-core обрабатывал оба источника одним кодом. Повод — старые ошибки распознавания (AURORA-1406, 1462, 1511, 1517, 1519, 1522, 1523, 1524, 1634): разбор конвейера показал, что это не отдельные баги, а свойство конвейера (issue #116).

Как работает сейчас

Процесс разбит на шаги, общие для обоих сервисов:

  • Шаг 0 — загрузка файла и доставка его до сервиса распознавания;
  • Шаг 1 — подготовка данных: разбор (ИС Сканирование) или чтение (парсер) сырых данных и базовая разметка — какие в файле формы, какого типа, где их шапка и таблица;
  • Шаг 2 и дальше — дальнейшая обработка (раскладка колонок, строки, значения, проверки, статус) — ещё не описаны.

Шаг 0. Загрузка и доставка до сервиса

Общая часть (arm-core):

  1. Пользователь загружает файл в карточку ЮЛ или в консолидацию (primary_files.upload_one): тип файла — по расширению и сигнатуре (domain/file_types.py), дедуп по (ЮЛ, sha256), файл — в S3, запись stored_file и recognition_job со статусом queued. Конвейер задания: Excel (xls / xlsx / xlsb) → tabular, PDF / PNG / JPG → image.
  2. Публикатор outbox (recognition_exchange.publish_pending) собирает запрос recognition-request — job_id, file_id, pipeline, file_type, original_name, ссылка на файл в S3, reply_deadline, attempt — и публикует его в subject конвейера: tabular → files.tabular_uploaded, image → files.img_uploaded (имена задаются окружением). Срок ответа по умолчанию: OCR — 20 мин, парсер — 5 мин. Нет ack 30 с — повторная публикация, после трёх повторов задание уходит в dlq, файл — ERROR.

В сервисе (одинаково в scan-gateway и tabular-parser):

  1. Запрос проверяется: JSON-объект, MAJOR версии контракта 1, pipeline свой (scan-gateway — только image), схема. Невалидный — SCHEMA_MISMATCH без ack. Повтор job_id, который уже в работе или обработан, — только повторный ack, второй обработки нет.
  2. Ack processing публикуется до чтения S3; задание ставится в очередь сервиса.
  3. Исходный файл читается из S3; не прочитан — FILE_READ_ERROR. scan-gateway дополнительно: бакет должен быть его единственным бакетом; если reply_deadline истёк до отправки — TIMEOUT.

Итог шага 0: у сервиса есть байты исходного файла и запрос (file_type, original_name, reply_deadline).

Шаг 1. Подготовка данных

1.1 Разбор и чтение сырых данных

scan-gateway (core/worker.py, gateway/) — разбор делает ИС Сканирование, не мы:

  1. Файл пишется во «Входящие» горячей папки как <job_id>.<ext> (расширение — из original_name, нижний регистр; нет расширения — RECOGNITION_FAILED). Горячая папка недоступна — RECOGNITION_FAILED.
  2. ИС распознаёт файл и кладёт в «Исходящие» <job_id>/ один или несколько json — по одному на распознанный комплект отчётности.
  3. Шлюз опрашивает каталог каждые SCAN_POLL_SECONDS (5 с). Набор готов, когда в нём есть *.json и имена и размеры не менялись SCAN_SETTLE_SECONDS (2 с). Срок ожидания — min(reply_deadline, запись во «Входящие» + SCAN_TIMEOUT_SECONDS) (1200 с); не дождались — TIMEOUT.
  4. Склейка (gateway/merge.merge_answers): каждый json разбирается независимо, по возрастанию имени → {job_id, files: [{name, response, parse_error}]}; json, который не разобрался, получает parse_error. Склейка сохраняется в S3 как raw/{job_id}.json до нормализации (не сохранилась — UNKNOWN_ERROR). Затем, только при SCAN_CLEANUP=true, горячая папка очищается.

Что внутри одного json ИС (по реальным ответам fixtures/ocr/*.json, §11):

  • корень {file_uid, error {code, message}, data_ocr}; успех — error.code == "", ошибка — непустой код (INVALID_DOCUMENT, RECOGNITION_FAILED, …), тогда таблиц нет;
  • data_ocr: поля шапки label, organization, inn, units, date — каждое {form_name, confidence}, сырой OCR-текст; шапка одна на json;
  • data_ocr.table_structure[] — таблицы форм, каждая {label {form_name, confidence}, rows[]}; строка — {row_index, cells[]}, ячейка — {column_index, value, confidence} (сырой текст, уверенность 0–100; column_index не обязательно подряд).

Наблюдается: json с таблицами содержит ББ и ОФР одного периода под общей шапкой (aep_3, aep_6, БО 1 кв 2025, Бухгалтерская отчетность 1 кв.25). Допущение владельца (17.10.2026): если в файле отчётность за несколько периодов, ИС отдаёт её разными json; образца нет — проверить на первом таком файле.

tabular-parser (parser/book.py, parser/readers/) — чтение делает сам парсер:

  1. Размер файла больше PARSE_MAX_BYTES (50 МБ) — UNSUPPORTED_FORMAT. Отсчёт PARSE_TIMEOUT_SECONDS (120 с) — от начала разбора.
  2. Формат определяется по сигнатуре байтов, а не по file_type запроса: OLE2 → XLS, zip с xl/workbook.bin → XLSB, иной zip → XLSX, прочее — UNSUPPORTED_FORMAT. Несовпадение с file_type — предупреждение. До чтения проверяются лимиты: распакованный размер zip, площадь объединённых ячеек.
  3. Книга читается в листы Sheet: только непустые ячейки (текст, значение, числовой формат), объединённые диапазоны, уровни группировки строк, габариты. Библиотека чтения упала — UNREADABLE. У xlsb стили и группировка не читаются — предупреждение.

1.2 Базовая разметка

scan-gateway (normalizer/ocr/__init__.py, header.py) — разметку уже сделала ИС, шлюз её только принимает:

  1. json набора обходятся по порядку. Ошибочный json (parse_error, непустой error.code, корень не объект) — предупреждение <имя>: <код>: <сообщение>, json пропускается. Все json ошибочны — error с кодом первого.
  2. Шапка json — из полей data_ocr: ИНН — только цифры, организация, дата (замена букв-двойников + разбор даты), единицы; у каждого поля — уверенность ИС. Предупреждения уровня файла: «ИНН не распознан», «ИНН распознан неуверенно», «дата отчёта не разобрана». Шапка общая для всех таблиц json.
  3. Каждая таблица table_structure[i] — кандидат в документ формы. Тип — по label (rules/statement_labels.yaml); уточнение типа по кодам строк сейчас делается позже, в шаге 2 (после разбора строк).

tabular-parser (parser/detect.py, parser/statements/detect.py, header.py) — разметку делает парсер, всё по содержимому (§12 п. 1, п. 6):

  1. Вид каждого листа: признак ведомости в первых 30 строках — ОСВ (дальше свой путь, в этом change не рассматривается); иначе заголовок формы или ОКУД — форма; маркер «Форма … с.2», шапка «Код» с периодами или колонка кодов — продолжение; лист с 1–2 кодами сразу после листа формы — тоже продолжение; остальное — прочее (предупреждение «нет признаков ОСВ или формы»).
  2. Формы на листе (find_forms): строки с заголовком («Бухгалтерский баланс», «Отчет о финансовых результатах», «…о прибылях и убытках») или ОКУД — кандидаты; кандидаты без строк с кодами между ними сливаются, заголовок после кодов открывает новую форму (две формы на одном листе). Тип — по ОКУД (0710001 → BALANCE, 0710002 → INCOME, прочие 0710xxx и «Пояснения…» → DISCLOSURE); заголовок и ОКУД противоречат — тип по ОКУД и предупреждение. Результат — блок формы: тип, form_name, okud, границы строк.
  3. Шапка — своя у каждой формы, ищется в границах её блока: организация, ИНН (без ИНН аудитора), ОКПО, ОКОПФ, ОКФС, единицы, дата отчёта, адрес; найденное поле — уверенность 100, не найденное — null; предупреждения «в шапке не найдена организация», «в шапке не найдена единица измерения».
  4. Листы-продолжения присоединяются к последней открытой форме — но решение требует раскладки колонок (совпадает ли число колонок сумм), поэтому фактически выполняется в шаге 2.

Итог шага 1: у обоих сервисов есть список кандидатов в документы форм — тип, шапка и сырые строки таблицы.

Различия на выходе шага 1

scan-gateway tabular-parser
Кто размечает ИС Сканирование парсер, по содержимому
Единица разметки json (комплект одного периода) → таблицы книга → листы → блоки форм
Шапка одна на json, общая для его таблиц своя у каждой формы
Тип формы label таблицы (уточнение по кодам — в шаге 2) ОКУД, иначе заголовок
Уверенность у каждого поля шапки и каждой ячейки, 0–100 нет (найдено — 100)
Многостраничная форма одна таблица = одна форма, склейки нет листы-продолжения присоединяются (в шаге 2)
Ячейка сырой OCR-текст текст, значение и числовой формат