2026-10-17 12:12:31 +04:00

33 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).

Как работает сейчас: цепочка шагов

Каждый шаг описан так: вход → что делает scan-gateway → что делает tabular-parser → выход → различия. Код: scan-gateway api/src/core/worker.py, gateway/, normalizer/ocr/; tabular-parser api/src/core/worker.py, parser/book.py, parser/readers/, parser/statements/. Правила: 05-domain-rules.md §7, §11, §11.1, §12.

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

Вход: файл пользователя.

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

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

Выход: байты исходного файла + запрос (file_type, original_name, reply_deadline).

Различия: нет, кроме сроков ответа и проверки бакета.

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

Вход: байты файла.

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

scan-gateway — разбор делает ИС Сканирование:

  1. Файл пишется во «Входящие» горячей папки как <job_id>.<ext> (нет расширения / папка недоступна — 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. Склейка: каждый json разбирается независимо, по возрастанию имени → {job_id, files: [{name, response, parse_error}]}; сохраняется в S3 raw/{job_id}.json до нормализации; затем (только при SCAN_CLEANUP) папка очищается.

Содержимое одного json (по fixtures/ocr/*.json, §11): корень {file_uid, error {code, message}, data_ocr}, успех — error.code == ""; data_ocr: поля шапки label, organization, inn, units, date — каждое {form_name, confidence} (сырой OCR-текст), одна шапка на json; table_structure[] — таблицы форм {label {form_name, confidence}, rows[{row_index, cells[{column_index, value, confidence}]}]}, column_index не обязательно подряд. Наблюдается: json с таблицами содержит ББ и ОФР одного периода под общей шапкой. Допущение владельца (17.10.2026): несколько периодов в одном файле ИС отдаёт разными json; образца нет — проверить на первом таком файле.

tabular-parser — чтение делает парсер:

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

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

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

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

Выход: список кандидатов в документы форм — признак типа, шапка, сырые строки таблицы (строки × ячейки).

Различия (шаг 1):

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

Шаг 2. Раскладка таблицы

Вход: сырые строки таблицы одной формы.

scan-gateway (layout.py, rows.parse_table_rows, §11.1 п. 3): шапка — строка с ячейкой, начинающейся на «код»; наименование — ячейка «наименование», иначе ближайшая заполненная слева от кода; колонки значений — правее кода, где в тексте четыре цифры года или текст начинается на «на » / «за ». Повторная шапка внутри таблицы перезаписывает раскладку (число колонок изменилось — предупреждение; значения строки берутся по позиции, лишние отбрасываются, недостающие — «ячейки нет»). Без шапки раскладка выводится по данным: код — столбец с наибольшим числом значений из справочника кодов, наименование — левее с наибольшим числом текстов, значения — правее, где суммы не реже мусора; шапка синтезируется, колонки без дат, предупреждение. Ни шапки, ни столбца кода — таблица пропускается («колонка кодов не найдена»). Колонка: label — текст шапки, period_date — разбор даты после замены букв-двойников, только если текст «размером с дату»; нет даты — «колонка N без даты».

tabular-parser (columns.find_layout, §12 п. 2–4): колонка кодов — столбец с наибольшим числом кодов (4–5 цифр, первые четыре в диапазоне формы), кодов ≥ 3 (у продолжения ≥ 1) и доля среди непустых ≥ порога; нет — документ без строк, «колонка кодов не найдена». Строка заголовка — последняя над первым кодом с подписью «Код», иначе последняя с двумя и более периодами правее кодов (или одним не голым годом); двухстрочный заголовок склеивается; строка нумерации «1 2 3 4 5» — часть заголовка. Колонки-периоды — правее кодов с распознанным периодом по словарю statement_periods.yaml; подпись без периода («Изменение», «%») пропускается, одно предупреждение со списком. Наименование — левее кодов с наибольшим числом текстов, кроме «Пояснения». Без заголовка (продолжение): колонки значений — где в строках с кодами есть числа, подпись пустая, дата null.

Выход: раскладка формы — столбец кода, столбец наименования, колонки значений [{label, period_date}], первая строка данных, строки, которые не данные (шапка, нумерация).

Различия (шаг 2):

№ scan-gateway tabular-parser
1 Что считается кодом при поиске столбца любое целое число (int(text)) 4–5 цифр в диапазоне формы (словарь)
2 Повторная шапка внутри таблицы перезаписывает раскладку не ищется (одна раскладка на блок)
3 Дата колонки «размером с дату» + двойники + parse_date словарь периодов period_end
4 Без шапки синтез шапки, колонки без дат, предупреждение колонки по числам, подпись пустая
5 Строка нумерации «1 2 3 4 5» не распознаётся (становится строкой данных, AURORA-1517) часть заголовка, в данных пропускается
6 Подпись без периода колонка значений, «без даты» колонка пропускается, предупреждение

Шаг 3. Отбор строк данных

Вход: раскладка + сырые строки.

scan-gateway (rows.py, §11.1 п. 4): склеенная строка («1500\n1700» с N частями в суммах) разлепляется на N (OQ-156, предупреждение-счётчик). Классификация по ячейке кода: пустой код — пропуск (есть суммы — счётчик «строк без кода с суммами пропущено»); нечисловой код («4 400\nI #4У», «ИЧ,250») — пропуск (счётчик «строк с нечисловым кодом пропущено»); целое число — строка данных (вид ROW / TOTAL / BALANCE по наименованию — дальше не используется). Повтор кода — остаётся первая, предупреждение «код N повторяется: оставлена первая строка». Пропущенные строки не восстанавливаются.

tabular-parser (lines.collect_lines, §12 п. 5): пустые строки пропускаются; код — 4–5 цифр в диапазоне, иначе строка «без кода»: нумерация «1 2 3 4 5» — пропуск; без кода с ненулевыми суммами — счётчик «строк без кода с суммами пропущено»; повтор кода — остаётся первая, код в duplicates.

Выход: строки данных формы — сырой код, сырое наименование, ячейки значений по колонкам; счётчики отброшенных.

Различия (шаг 3):

№ scan-gateway tabular-parser
1 Код-мусор отдельный счётчик «нечисловой код» не отличается от «без кода»
2 Склейка двух строк в одну разлепляется (OQ-156) нет такого случая
3 Повтор кода предупреждение текстом checks.duplicate_line_codes
4 Отброшенная строка только счётчик; строка исчезает (AURORA-1634, 1462) только счётчик; строка исчезает

Шаг 4. Значения ячеек

Вход: ячейки значений строки.

scan-gateway (amounts.parse_amount, document._value, §11.1 п. 7): пробелы удаляются первыми; запятая / точка как разделитель тысяч по шаблонам (4,200, 837.994); запятая → точка; скобки → минус; из строки выбрасывается всё, кроме цифр, +, -, .; больше одной точки — все удаляются; больше двух знаков после точки — amount = null, текст в string_value. Прочерк / пусто — null. is_recognized = confidence == 100 и при разборе не выбрасывались символы. Ячейки под колонкой нет — amount = null, confidence = 0, is_recognized = false.

tabular-parser (lines._value, statement_money, number_format.display_amount, §12 п. 5): число с числовым форматом — как показывает формат ячейки (масштабирующие запятые → тысячи, число знаков, секции и условия, округление half-up; формат не разобран — сырое значение и одно предупреждение на документ); иначе to_money: Decimal, скобки → минус, прочерк / пусто → null, нечисловой текст → string_value, is_recognized = false. confidence всегда 100. Счётчики дробных значений: при «тыс.» и ≥ 10 % дробных — предупреждение.

Выход: StatementValue {column_index, amount, string_value, confidence, is_recognized}.

Различия (шаг 4):

№ scan-gateway tabular-parser
1 Предобработка текста OCR-мусор: пробелы, двойники, тысячи, скобки } / ] числовой формат ячейки Excel
2 Смысл is_recognized уверенность 100 ∧ ничего не выброшено текст разобрался как число
3 «42831 1» пробел удалён → 428311, «чистое» (AURORA-1511) не бывает
4 Единицы не проверяются предупреждение о дробных при «тыс.»
5 Пустая ячейка там, где была колонка null, confidence ИС null, 100

Шаг 5. Код, наименование, редакция строки

Вход: строка данных с сырым кодом и наименованием.

scan-gateway (document._line, _choose_edition, §11.1 п. 6): редакция формы — по дате отчёта, иначе по самой поздней дате колонок, иначе по кодам (предупреждение); код очищается до букв, цифр, пробела и точки; наименование — из справочника редакции по коду (api/rules/statement_lines.yaml шлюза), уверенность кода и имени = уверенность ячейки кода, если текст чистый и код в справочнике, иначе min(conf, 99); кода нет в справочнике — имя из OCR со своей уверенностью, код min(conf, 99), предупреждение «код N из редакции …», если он есть в другой.

tabular-parser (lines.collect_lines, _line_name): код как найден (4–5 цифр); наименование — из файла: ячейка колонки наименований, если пуста — ближайшая текстовая левее кодов, кроме «Пояснения»; уверенность кода 100, имени 100 (пусто — null). Редакции и справочника нет.

Выход: StatementLine {line_code, line_code_confidence, line_name, line_name_confidence, values}.

Различия (шаг 5):

№ scan-gateway tabular-parser
1 Источник наименования справочник по коду (AURORA-1406, 1522) файл
2 Редакция (66н / ФСБУ 4) определяется, влияет на имена не определяется
3 Справочник строк свой statement_lines.yaml (не совпадает с ядром) нет
4 Уверенность кода от ИС, срезается до 99, если не в справочнике всегда 100

Шаг 6. Сборка документа

Вход: раскладка, строки, шапка кандидата.

scan-gateway (document.build_document, _resolve_type): тип — по label; все коды одной группы и противоречат label — тип по кодам с предупреждением; label не сопоставлен — по большинству кодов. Колонки — из первой шапки. locator = file:<json>;table:<i>. Шапка — поля json (общая).

tabular-parser (statement._document, merge): лист-продолжение присоединяется к последней открытой форме, если число колонок сумм совпало и тип в маркере не противоречит (слабое продолжение с 1–2 кодами — ещё и коды из диапазона формы); не присоединился — одинокое продолжение с пустой шапкой и предупреждение. locator = sheet:<A>,<B>. DISCLOSURE и форма без колонки кодов — без columns / lines.

Выход: StatementDocument {document_type, locator, header, columns, lines} (без checks).

Различия (шаг 6):

№ scan-gateway tabular-parser
1 Тип формы label, сверка с кодами ОКУД / заголовок, с кодами не сверяется
2 Склейка страниц нет листы-продолжения
3 locator file:…;table:i sheet:A,B

Шаг 7. Самопроверки

Вход: документ.

scan-gateway: не делает; checks отсутствует («не проверялось»).

tabular-parser (statements/checks.run_statement_checks, §12 п. 7): соотношения §7 по каждой колонке из statement_checks.yaml: жёсткие → relations_failed, мягкие → soft_relations_failed; допуск ±1, округление 0,001 % уводит жёсткое в мягкие; abs_terms; «родитель = Σ подстрок» только при непустой детализации (AURORA-1527); повторы кодов → duplicate_line_codes; предупреждения текстом на каждое нарушение.

Выход: StatementChecks или ничего.

Различия (шаг 7): есть только у парсера; словарь §7 у парсера и ядра — две копии; ядро результат парсера не читает, считает заново.

Шаг 8. Статус и предупреждения

Вход: документы и накопленные предупреждения.

scan-gateway (_Build.fully_recognized, _normalize_file, normalize): документ «полностью распознан», если ни одного предупреждения, все ячейки is_recognized, уверенность кода и имени 100, ИНН и организация с уверенностью 100, дата распознана. Файл — все документы распознаны ∧ нет предупреждений файла. Конверт done ⇔ все файлы распознаны ∧ ни один json не ошибочен; иначе needs_check; ни одной таблицы — error RECOGNITION_FAILED. Предупреждения — плоские строки <json>: table:<i>: <текст> в metrics.warnings.

tabular-parser (statement._is_ok, book.parse): документ ok ⇔ колонка кодов найдена ∧ не одинокое продолжение ∧ есть колонки ∧ в шапке организация или ИНН ∧ checks есть ∧ нет жёстких нарушений ∧ нет повторов кодов; DISCLOSURE всегда ok. Книга done ⇔ все документы ok ∧ ни один лист не потерян; книга без листов ОСВ и форм — INVALID_DOCUMENT. Предупреждения — строки sheet=<имя> <текст> в metrics.warnings; на статус не влияют.

Выход: ResultStatus конверта + metrics.warnings[].

Различия (шаг 8):

№ scan-gateway tabular-parser
1 Критерий done всё с уверенностью 100 и без предупреждений структура найдена, проверки сошлись
2 Влияние предупреждений любое → needs_check не влияют
3 Статус документа нет (только конверт) внутренний ok, в контракт не попадает
4 Формат предупреждений <json>: table:<i>: … sheet=<имя> …
5 Привязка предупреждения к файлу к документу (по префиксу)

Общее для обоих: статус один на конверт; предупреждения — свободный текст без привязки к строке или ячейке; ядро и экран не могут показать, что именно и где не так.

Шаг 9. Результат

Одинаково: RecognitionResult (schema_version, job_id, file_id, status, documents, metrics) → S3 results/{file_id}/{job_id}/result.json → конверт в files.tabular_parsed с Nats-Msg-Id = result:<job_id>:<attempt>. У шлюза в конверте ещё raw_s3_key.

Что с результатом делает ядро (для полноты картины)

  • result_mapping.map_file → statement_mapping.build_statement: повторно отбрасывает повторы колонок, кодов и значений (защита от обоих производителей), пишет financial_statement_* как пришло.
  • statement_status (D-6): RECOGNIZED ⇔ конверт done ∧ проверки §7 пересчитаны ядром и сошлись ∧ ИНН не MISMATCH ∧ период известен ∧ все ячейки с уверенностью 100 и is_recognized ∧ есть строки ∧ нет предупреждений маппинга; иначе NEEDS_CHECK. checks парсера не читаются; metrics.warnings сервиса на статус не влияют. RECOGNIZED сразу автоподтверждается.
  • GET …/statement: §7 считается заново по словарю ядра (копия словаря парсера); недостающие строки достраиваются из statement_lines.yaml ядра (не совпадает со справочником шлюза); предупреждения в ответе отсутствуют.
  • Экран: ячейка подсвечивается только по confidence < 100 или is_recognized = false; нарушения §7 — списком без привязки к ячейкам; предупреждения сервиса и маппинга нигде не показываются.

Бойлерплейт и потери, которые порождены различиями шагов 2–8: три копии словарей (§7 в парсере и ядре, строки в шлюзе и ядре); двойная дедупликация; пересчёт проверок; статус, который ядро собирает заново из косвенных признаков; три несвязанных канала качества (уверенность ячейки, проверки §7, предупреждения), из которых пользователь видит полтора.

Целевая картина

Шаг 1 — решения владельца по различиям (17.10.2026)

  1. Кто размечает — различие остаётся: у шлюза разметку делает ИС, у парсера — сам парсер. Это природа источников.
  2. Единица разметки — на выходе шага 1 у обоих сервисов одинаковый контракт. Форма контракта (json или промежуточные объекты в коде сервиса) — решается отдельно.
  3. Шапка — у каждой таблицы своя. Шлюз отдаёт шапку json каждой его таблице.
  4. Тип формы — определяется единым подходом в обоих сервисах (правило — открытый вопрос).
  5. Уверенность — есть и у парсера, всегда 100: формат один, значение у Excel-пути постоянное.
  6. Многостраничная форма — парсер склеивает листы-продолжения в шаге 1; на выходе шага 1 одна форма — одна таблица, как у ИС.
  7. Ячейка — один общий формат для обоих сервисов (состав — открытый вопрос).

Открытые вопросы шага 1: форма контракта (п. 2), правило типа формы (п. 4), формат ячейки (п. 7), как парсеру склеивать продолжения до шага 2 (п. 6: сейчас склейка опирается на раскладку колонок).

Шаги 2–8 целевой картины — по ходу brainstorming, по той же схеме: решение по каждой строке таблицы различий.