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

333 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Единый алгоритм нормализации ББ/ОФР (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, по той же схеме: решение по каждой строке таблицы различий.