docs(#116): change unified-statement-normalization — как работает сейчас, шаги 0 и 1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
26e23ca85b
commit
940c541052
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-17
|
||||
131
openspec/changes/unified-statement-normalization/design.md
Normal file
131
openspec/changes/unified-statement-normalization/design.md
Normal file
@ -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. Файл пишется во «Входящие» горячей папки как `<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-текст | текст, значение и числовой формат |
|
||||
Loading…
x
Reference in New Issue
Block a user