From 940c541052f247006da598ddc822298bba4d3082 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 17 Oct 2026 11:48:10 +0400 Subject: [PATCH] =?UTF-8?q?docs(#116):=20change=20unified-statement-normal?= =?UTF-8?q?ization=20=E2=80=94=20=D0=BA=D0=B0=D0=BA=20=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=D0=B5=D1=82=20=D1=81=D0=B5=D0=B9=D1=87=D0=B0?= =?UTF-8?q?=D1=81,=20=D1=88=D0=B0=D0=B3=D0=B8=200=20=D0=B8=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../.openspec.yaml | 2 + .../unified-statement-normalization/design.md | 131 ++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 openspec/changes/unified-statement-normalization/.openspec.yaml create mode 100644 openspec/changes/unified-statement-normalization/design.md diff --git a/openspec/changes/unified-statement-normalization/.openspec.yaml b/openspec/changes/unified-statement-normalization/.openspec.yaml new file mode 100644 index 0000000..b699b88 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-17 diff --git a/openspec/changes/unified-statement-normalization/design.md b/openspec/changes/unified-statement-normalization/design.md new file mode 100644 index 0000000..bfdceee --- /dev/null +++ b/openspec/changes/unified-statement-normalization/design.md @@ -0,0 +1,131 @@ +# Единый алгоритм нормализации ББ/ОФР (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): + +3. Запрос проверяется: JSON-объект, MAJOR версии контракта 1, `pipeline` свой (scan-gateway — только `image`), схема. + Невалидный — `SCHEMA_MISMATCH` без ack. Повтор `job_id`, который уже в работе или обработан, — только повторный ack, + второй обработки нет. +4. Ack `processing` публикуется до чтения S3; задание ставится в очередь сервиса. +5. Исходный файл читается из 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. Файл пишется во «Входящие» горячей папки как `.` (расширение — из `original_name`, нижний регистр; нет + расширения — `RECOGNITION_FAILED`). Горячая папка недоступна — `RECOGNITION_FAILED`. +2. ИС распознаёт файл и кладёт в «Исходящие» `/` **один или несколько 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-текст | текст, значение и числовой формат |