IFRS Pipeline — полный процесс обработки PDF

От входящего файла до Excel: роутинг страниц → OCR → структура таблиц → аннотация → эмбеддинг-ранжирование → fast-path/LLM → постобработка → выход. По коду _код_пайплайна_260825.
15 стадий
4 аннотатора
14 моделей
61 показатель
9 sidecar-форматов
1 LLM (fallback)

Полный процесс — одной схемой

Пятнадцать стадий. Тяжёлые вычисления (OCR/docling/layout) выполняются один раз и кешируются в sidecar-файлах рядом с PDF; извлечение можно перезапускать без повторной обработки.
Модели: OCR Docling Структура/layout Эмбеддинги LLM Язык.модель Правила
0

Маршрут PDF по пайплайну

15 стадий · 4-я фаза — GPU
  • Вход → PDF читается PyMuPDF (fitz): страницы рендерятся в растры для моделей, текст извлекается для разбора
  • Роутинг страниц → каждая страница классифицируется: нормальный текстовый слой / скан / битый или неполный слой
  • OCR (только маршрутизированные страницы) → слова с координатами → sidecar *_ocr.words.json + невидимый слой *_ocr.pdf
  • Docling (GPU) → ячейки таблиц с grid (row/col) → *_docling.json
  • Layout → заголовки нот PP-DocLayout → *_layout.json → note_index
  • Аннотация → страница превращается в строки с тегами колонок [тек:X] [пред:Y] [прим:N]
  • PdfRow → каждая строка таблицы = объект (метка + значения колонок + секция)
  • Обогащение → секции BS, тип таблицы (A=E+L), склейка разорванных отчётов, tabnorm-стор
  • Эмбеддинг-матчинг → метки строк кодируются, ранжируются против 61 показателя (гибрид 2 моделей)
  • Fast-path → guards (секция/знак/масштаб) → значение из тега. null → шаг 11
  • LLM-фолбэк → DeepSeek читает аннотированный текст страницы, majority-vote k=3, null-audit
  • Постобработка → знаки, агрегаты из графа, кросс-отчётная сверка, дубликаты
  • Выходextracted.json → Excel (ticker.xlsx), агрегация базы
  • 1. Вход: PDF и его страницы

    Файл открывается PyMuPDF (fitz). Из него получают два представления: растр (картинка страницы — для OCR/детекторов) и текст (для парсинга строк).
    PDFreport.pdf
    PyMuPDF (fitz)страницы, размеры, /Rotate
    Растрget_pixmap ×2.0 hi-res
    Текстextract_text + слова pdfplumber

    Что уже известно на входе

    Размер страницыв pt (A4/ландшафт 842×595 у WTCM)
    Поворот/Rotate: 90/180/270 — ландшафтные таблицы часто повёрнуты
    Текстовый слойесть/нет/битый — решает роутинг (стадия 2)
    Кешиsidecar'ы рядом с PDF — если свежие, тяжёлые стадии пропускаются
    Hi-res рендер: 2.0× + Lanczos-ужатие до ширины 880px — RT-DETR (layout) слаб на мелких объектах, рендер должен быть чётким, но не растянутым.
    1.1

    Повороты страниц: что и как

    до OCR · по странице
  • Два класса поворотов: (а) метаданные PDF /Rotate (90/180/270) — часто заданы неверно; (б) физически повёрнутый скан — ландшафтную таблицу отсканировали боком
  • Триггер: страница «мусорная» — _real_word_count нашёл мало осмысленных слов. Тогда текст читается боком и данные теряются
  • Определение угла: модель PP-LCNet_x1_0_doc_ori предсказывает 0/90/180/270 — 0.004 с на GPU против 0.68 с у Tesseract-OSD «при ТОМ ЖЕ вердикте»
  • Знак поворота — проверен экспериментом: img.rotate(+rot), НЕ -rot — минус доворачивал на 180° и распознаватель честно читал перевёрнутый текст («икнәпd»: 70 слов из 208, почти без кириллицы). С плюсом WTCM стр.12 → 208 слов (142 кириллицей)
  • Валидация (tesseract-ветка): повернуть ТОЛЬКО если осмысленных слов стало кратно больше (> max(n×2, n+20)) — guard от ошибок OSD
  • Что поворачивается: растр для OCR (rotate expand), а для docling — векторный PDF с повёрнутыми контурами глифов: структура таблицы сохраняется, а не рушится растровым ресемплом
  • Координаты: флаг reoriented — слова после поворота возвращаются в систему координат оригинальной страницы, иначе docling видит ячейки по картинке, а текста в них нет (грид разваливался на WTCM стр.24)
  • Тонкий наклон (десятые градуса) — отдельный deskew: при 0.9° осевой кроп справа захватывает по диагонали соседние строки → смазь («Прочие доходы» → «Прочиедс»)
  • PP-LCNet_x1_0_doc_ori Tesseract OSD валидация по числу слов векторный поворот для docling

    2. Роутинг страниц: скан или цифровой?

    Не весь документ одинаков: даже «цифровой» PDF может содержать сканы (страницы-картинки) и страницы с повреждённым текстовым слоем. Каждая страница классифицируется отдельно.
    Страница
    символов текста достаточно?
    шрифты с ToUnicode?
    ✓ текстпропустить OCR
    ✗ сканв OCR
    ⚠ битый слойв OCR (замена)

    Как определяется «скан»

  • _find_scanned_pages — на странице нет/почти нет извлекаемого текста (пустой текстовый слой = страница-картинка)
  • _find_corrupt_native_pages — текст есть, но шрифты без ToUnicode-карты: извлечение даёт мусор (кракозябры) → такой слой бесполезен
  • _find_incomplete_textlayer_pages — слой частичный: часть страницы — картинки
  • Цифры из прогона: 21 документ, 1316 страниц → OCR понадобился на 185 (14%). WTCM — 73/73 (100%), UWGN — 53/59 (90%). Именно на этих страницах живут почти все расхождения извлечения.
    2.1

    ToUnicode-карты: почему «битый текстовый слой» — битый

  • PDF не хранит текст как Unicode. Внутри файла текст — это последовательность кодов глифов (чисел), а шрифт — таблица «код → нарисованный символ». Само число ничего не говорит: код 45 в одном шрифте — буква «В», в другом — «Ф»
  • ToUnicode (CMap) — специальная таблица внутри шрифта, которая сопоставляет код с Unicode-символом: «45 → U+0412 (В)». Это мостик между «как нарисовано» и «что это за символ»
  • С картой — PyMuPDF/pdfplumber корректно извлекают текст. Без карты — извлечение даёт мусор: случайные символы, пустоту или «кракозябры» — слой формально есть, но бесполезен
  • Почему карт нет: старые/«левые» генераторы PDF (особенно из 1С-подобных систем и принтерных драйверов) не встраивают ToUnicode. Иногда — намеренная обфускация (защита от копирования текста): карта есть, но врёт
  • Что делает пайплайн: страницы с битым слоем попадают в _find_corrupt_native_pages → маршрутизируются в OCR как сканы (текст переснимается с картинки, карта не нужна)
  • Отдельный инструмент: glyph_decode.py — декод обфусцированных шрифтов по glyph-ID (по нарисованному образу символа, а не по карте) — для случаев, когда OCR нежелателен
  • Простой тест «битый ли слой»: извлечь текст со страницы — если это осмысленные слова, карты на месте; если «Ð¤Ð¸Ð»Ðµ» или пусто при видимом тексте — слой битый → OCR.

    3. OCR: распознавание сканов

    Отдельный модуль pdf_text_restore.py (свой venv_paddle). Выполняется только для маршрутизированных страниц. Результат — слова с координатами в PDF-пунктах.
    3.1

    Конвейер распознавания

  • Ориентация — PP-LCNet_x1_0_doc_ori: страница повёрнута? (270°-сканы)
  • Детект рамок — ОДИН из трёх детекторов, выбирается переменной окружения OCR_DET (они НЕ работают вместе — это переключатель, не ансамбль)
  • Маршрутизация слова: цифры → eslav-модель, кириллица → cyrillic_v2 (дообучена), латиница/1 символ → Tesseract
  • Арбитр — char-LM (n-граммы по корпусу отчётности) решает споры моделей
  • OCR_DETРамкиСкоростьСильные стороныСлабости
    tesseract
    (дефолт)
    словесные сразу + conf + группировка в строки (block/par/line) 3.97 с/стр даёт conf и структуру строк; надёжные bbox медленный (68% времени OCR); режет края глифов («конец» без «ц»)
    paddle строчные → нарезка на слова (cal_ocr_word_box) 0.01–0.02 с/стр (×200-320) скорость; ориентация своей моделью не даёт conf; нужна нарезка строк на слова
    doctr словесные сразу (db_resnet50, ONNX) 0.31 с/стр чистые рамки: 0 пустых/дублей (у tesseract 3/5/2/29), накрытие чернил 91.2% vs 87.9%; чинит разрез слов, не даёт рамок на 2 строки не отдаёт текст и conf → маршрутизация идёт арбитром
    Почему «или»: это переключатель для A/B-отката. Каждый детектор решал свою проблему (tesseract — исторически первый; paddle — скорость, профиль показал 5.74 из 6.4 с/стр на нём; doctr — чистота рамок). Гейт держит все три живыми, чтобы мгновенно сравнивать/откатываться. В наших прогонах использовался OCR_DET=paddle.
    PP-LCNet doc_ori PP-OCRv5_mobile_det eslav_PP-OCRv5_mobile_rec cyrillic_v2 (дообучена) db_resnet50 (docTR) Tesseract char-LM
    3.2

    Формат хранения — sidecar

  • *_ocr.pdf — копия со вшитым невидимым текстовым слоем (render_mode=3): дальше с ней работают pdfplumber/docling как с обычным PDF
  • *_ocr.words.json — слова с рамками в pt: источник координат для аннотации
  • *_ocr.pages.json — кеш разобранных страниц
  • // *_ocr.words.json — формат слова
    {"text": "земельный", "x": 138.96, "x1": 146.52,
     "y": 322.92, "y1": 339.84}
    Кеш по mtime: sidecar свеж, пока новее PDF и скрипта OCR. Повторный прогон не пере-OCR'ит.

    4. Docling: структура таблиц

    Единственный CUDA-потребитель (TableFormer). Даёт авторитетный grid таблиц: какая ячейка в какой строке/колонке, объединения.
    PDF (с OCR-слоем)
    layout-heronблоки страницы
    TableFormer v1/v2ячейки + row/col
    *_docling.jsonсторона на страницу
    4.1

    Особенности режима

  • OCR='precomputed' — распознавание RapidOCR заменяется НАШИМИ словами (из words.json): TableFormer получает позиции токенов, но текст не переписывается
  • do_cell_matching=False — структура берётся из TableFormer, привязка текста к ячейкам делается downstream (по bbox), чтобы не слипались фразы
  • Чанки ≤40 страниц — RAM-плато: большой PDF обрабатывается кусками в subprocess
  • Один конвертер на батч — freeze-safe (многократный GPU-init дедлочит Blackwell)
  • // *_docling.json — таблица
    {"n_rows": 17, "n_cols": 13,
     "cells": [{"bbox": [52.54, 93.9, 146.88, 102.74],
                "row": 0, "col": 0, "rowspan": 1, "colspan": 1}]}
    Реальная развилка: на стр.24 WTCM docling видит 1 таблицу 17×13 (209 ячеек), PP-StructureV3 — 2 таблицы (16×13 + 16×7, 305 ячеек). Разный grid → разные адреса ячеек → разные результаты извлечения.

    5. Layout: заголовки нот

    PP-DocLayout (RT-DETR, PaddleOCR 3.x) находит границы примечаний — «Примечание 14 — Основные средства». Это строит note_index: карту «номер ноты → страница».
  • Страница рендерится hi-res (ширина ≥880px — RT-DETR слаб на мелких объектах, обучался на 640×640)
  • Детектор возвращает регионы figure_title с текстом и score
  • Собирается note_index: «Примечание N → страница», по нему работает роутинг показателя к нужной ноте ([прим:14] → страница 44)
  • // *_layout.json
    {"label": "figure_title", "score": 0.851,
     "bbox": [51.63, 71.78, 447.95, 82.29],
     "text": "(i) Основные допущения, применяемые при использовании доходного подхода"}
    LayoutDetection (RT-DETR)
    5.1

    Что такое «заголовки нот»

  • Ноты (примечания, notes) — обязательный блок МСФО-отчётности, идущий ПОСЛЕ трёх основных отчётов (баланс, ОПУ, ОДДС). Это развёрнутые пояснения: от «Примечание 1 — Общие сведения» до «Примечание 25 — Финансовые инструменты»
  • Структура ноты = заголовок («14. Основные средства») + раскрытия: rollforward ОС (поступления/выбытия/амортизация), матрицы погашения, сроки, ставки — как правило, таблицы. Именно здесь живут «вторичные» показатели (амортизация по классам, обесценение, аренда §53)
  • Зачем пайплайну заголовки: каждая строка основного отчёта ссылается на свою ноту — тег [прим:14] в строке «Основные средства». Чтобы найти данные ноты, нужно знать, на какой странице начинается «Примечание 14». Эту карту («номер ноты → страница») и даёт детектор заголовков
  • Как детектятся: PP-DocLayout (RT-DETR) находит на страницах регионы-заголовки с текстом вида «14. Основные средства», «(i) Основные допущения…» — это и есть «заголовки нот» (и подзаголовков внутри нот)
  • Зачем нужен note_index — реальный маршрут: строка баланса [прим:14] → карта говорит «нота 14 на стр. 44» → пайплайн идёт на стр. 44 искать rollforward амортизации, а не листает все 73 страницы
  • Дополнительно ноты классифицируются по типу (по заголовку + содержимому): ppe_rollforward (развёртка ОС), rou_lease (аренда), intangibles_rollforward (НМА), related_parties — это помогает искать нужную таблицу адресно
  • Реальный пример из прогона WTCM (лог): «индекс нот: 33 (heading-граница, номера [1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 13, 14, 15, 16])» + «note subtypes detected: ppe_rollforward: 5, rou_lease: 3, intangibles_rollforward: 2, related_parties: 3». То есть 33 заголовка нот найдено, и по типам распределены — дальше роутинг работает адресно.

    6. Аннотация: таблица → тегированные строки

    Сердце табличного пути. Страница превращается в текст, где каждая строка таблицы несёт теги колонок. Дальше пайплайн работает ТОЛЬКО с этим текстом.
    6.1

    Четыре аннотатора (по приоритету)

    АннотаторКогдаИсточник геометрии колонок
    _annotate_from_ppstructureесть PP-Structure sidecarсетка ячеек row/col напрямую
    _annotate_from_doclingесть docling sidecarячейки TableFormer + геометрия заголовков
    _annotate_hybridOCR-страницыстроки-даты «2025/2024» по x → кластеры колонок
    _annotate_table_linesнативный PDF (фолбэк)кластеризация чисел по x-позициям
    6.2

    Результат аннотации

    // Заголовок колонок (первая строка блока)
    # Колонки: [тек:] = 2025  |  [пред:] = 2024
    
    // Строки таблицы
    Основные средства [тек:121 769 257] [пред:108 901 174] [прим:14]
    Выручка [тек:138 151 147] [пред:128 454 108] [прим:25]
    Запасы [тек:4 934 329] [пред:4 380 625]
  • [тек:] — текущий период (скобки → минус: «(5 402)» → -5402)
  • [пред:] — предыдущий период
  • [прим:N] — номер примечания (роутинг к ноте)
  • Прочерк «–» в колонке = NIL (0), не часть метки
  • 6.3

    Особые таблицы: rollforward

  • Развёртки движения активов («Нач. / Поступления / Выбытие / Итого») распознаются по заголовку → ⚠ ТАБЛИЦА-РАЗВЕРТКА
  • Колонки там — категории, а не периоды: перетегируются в [тек:Итого] [пред:Нач.]
  • Так «Амортизация» из rollforward становится строкой с итогом и начальным остатком
  • 6.4

    Логика: docling-ячейки → теги (главная механика)

    docling даёт grid, но НЕ говорит, где какой период
  • 1. Слова раскладываются по ячейкам — каждое OCR/нативное слово попадает в ту ячейку docling, где лежит его центр (по bbox)
  • 2. Колонки получают РОЛИ. Docling знает только «ячейка (row, col)» — но не «это текущий год» или «это номер примечания». Роли определяются содержимым верхних строк:
    • год-токены («2025», «2024») в разных x-позициях → колонки current/previous
    • узкая колонка, где только числа 1–99 → колонка примечаний (note)
    • первая текстовая колонка → метка строки (label)
    • остальные числовые → значения
  • 3. Физические строки свёртываются в логические — таблицы в PDF часто переносят длинную метку на 2 строки: хвост строчными буквами клеится к предыдущей строке; строка с ЗАГЛАВНОЙ без значения = голова нового line-item
  • 4. «Утечки» возвращаются в метку — буквенный токен, попавший в числовую колонку (хвост метки, уехавший по геометрии: «…дебиторской [прим:задолженности]») возвращается в метку — иначе косинус проседает
  • 5. Inline-примечания — «(Прим. N)» внутри метки ловится позиционно, если отдельной note-колонки нет
  • 6. Эмиссия строки: метка [прим:N] [тек:X] [пред:Y] + служебный заголовок # Колонки: и титул таблицы ⟪HDR:…⟫ (для титул→таблица)
  • Почему это хрупко: год-токены в шапке могут отсутствовать (перевёрнутая шапка на скане), узкая note-колонка сливается с label, а значения диапазона («2026-2090 гг.») маскируются под год — каждый такой случай — отдельная война в коде (видно по 5000+ строкам аннотации).

    7. PdfRow: строка-кандидат

    Тегированный текст парсится в объекты. Это единица, с которой работает эмбеддинг и guards.
    // extract_table_rows → PdfRow (dataclass)
    PdfRow(
      page_num = 24,
      label    = "Основные средства",   // метка БЕЗ тегов
      numbers  = [121769257.0, ...],    // все числа строки
      tek      = 121769257.0,           // [тек:] — знак уже разрешён
      pred     = 108901174.0,           // [пред:]
      note     = "14",                  // [прим:N]
      section  = "внеоборотные активы", // раздел BS (позже)
      cf_section = None,                // секция ОДДС
      stmt_type  = "primary_bs",        // тип таблицы-блока
      blk        = 2,                   // индекс docling-таблицы
    )

    Почему label чистится от тегов

  • Теги [тек:]/[пред:]/[прим:] снимаются из метки целиком — иначе метка режется по первой цифре внутри тега, и в эмбеддинг уходит мусор («…по аренде [тек:»)
  • Значение хранится отдельно (tek/pred) — оно уже «разрешило колонку и знак»
  • Голая строка-субитог (тег без метки) сохраняется с пустой меткой — по ней секц-детект восстанавливает разделы
  • 8. Обогащение строк

    Сырые PdfRow получают контекст: в каком разделе баланса строка, что это за таблица, не разорван ли отчёт поперёк страниц.
    8.1

    Секции BS

  • _tag_row_sections: строки-итоги («Итого внеоборотные активы») размечают раздел для всех строк ниже
  • Голая строка-субитог без слова «итого» (стиль wtcm) → раздел по компонентам (_section_from_suffix_anchors: ОС/гудвил → внеоборотные; ДС/запасы → оборотные)
  • 8.2

    Тип таблицы

  • _classify_table_block: таблица = primary_bs, если её строки замыкают тождество А = Е + L
  • Так баланс опознаётся даже без заголовка — арифметикой
  • 8.3

    Склейка разорванных отчётов

  • _stitch_statement_continuations: баланс на 3 страницах склеивается по геометрии блока (blk)
  • Continuation-by-geometry — обобщение для BS/PL/CF (IAS 1 §54/§82, IAS 7 §45)
  • 8.4

    tabnorm-стор

  • Все НЕстандартные таблицы (rollforward/матрицы погашения) нормализуются в стор
  • Извлечение = запрос t.value(section, row, column, period)
  • Их строки вливаются в тот же пул PdfRow с контекстом «секция+строка+колонка»
  • 9. Эмбеддинг-матчинг: ранжирование строк

    Главный механизм выбора. Метки всех строк кодируются один раз на документ, затем ранжируются против 61 показателя.
    Метки строкlowercase, без тегов, row_label
    ifrs-embed-v13XLM-RoBERTa 768d
    bge-m3α=0.15
    _row_cacheстраница → матрица
    9.1

    Три места ранжирования

  • Строки vs концепт — косинус каждой строки против показателя+392 синонимов → confident/soft/uncertain
  • Страницы под категорию — балл страницы = Σ sim × вес (своя primary → boost, примечания → ×0.3)
  • best_row с margin — top-1 на лучшей странице, но отрыв от top-2 решает: маленький margin → null → LLM
  • // sim = (1-α)·custom + α·bgem3,  BGE_ALPHA = 0.15
    // custom gap=0.283, bgem3 gap=0.003 — bge-m3 почти не различает IFRS-концепты;
    // α нужен только для случаев custom_sim < 0
    
    «Выручка»                    sim 0.982   ← best_row
    «Выручка от оказания услуг»  0.911
    «Итого выручка»              0.904
    «Прочие доходы»              0.342

    10. Fast-path: извлечение без LLM

    Детерминированный путь: top-строка проходит через guards, значение берётся из тега. Это 80-90% показателей.
    best_rowtop-1 по sim
    Guardsсекция · знак · масштаб · inverse-total
    Значениеиз тега [тек:]
    null?→ LLM (стадия 11)

    Guards — фильтры, превращающие косинус в надёжное решение

    GuardЧто отсекаетПример из кода
    sectionстрока не в ожидаемом разделе BS«Долгосрочные кредиты» должны быть в долгосрочных обязательствах
    signзнак тега противоречит роли статьи«Прочие операционные ДОХОДЫ» вместо РАСХОДОВ (близкий эмбеддинг, разный знак)
    scaleмелкое целое при среднем косинусе = чужая строкаx5 «Доходы от аренды»=26 при верном 29 726 (sim 0.79 → null)
    inverse-totalагрегат вместо компонентыне брать «Операционные расходы»-итог, когда нужны компоненты
    Значение из тега: «теги уже несут правильное значение колонки со знаком, без колонки примечаний и прозы — это ломало sofl/mtss».
    10.1

    Логика: как guards принимают решение

    порядок каскада · точные пороги
  • 0. Margin-guard (первый фильтр) — отрыв top-1 от top-2 на странице. Малая маржа = отказ, НО: в ~40% случаев top-1 был верным, просто с близким соседом. Поэтому отказ смягчается для «уверенной структурной находки»: высокий sim (≥0.95, не 0.90 — при 0.90 проскакивали однознаковые соседи: варианты CF-потоков, сегментная выручка) + чистый тег [тек:] + страница не чужого отчёта. Для guarded-показателей (ДС «на конец») маржа вообще не нужна — guard уже отсеял «начало»
  • 1. Label-guard для итогов — «Итого X» обязан матчить строку со словом «итого/всего/total». Иначе «Прочие внеоборотные активы» (похожий эмбеддинг) примется за «Итого внеоборотные активы»
  • 2. Доменные guards (пример: финвложения §54d/e) — строка «финвложений» должна нести финансовый признак (ценные бумаги, депозиты, займы выданные) и НЕ быть долевой инвестицией/недвижимостью. Отсекает irao «Прочие внеоборотные активы» с чужим числом
  • 3. Sign-guard — для статей с однозначной полярностью: у строго-положительных (выручка) тег со знаком «−» = неверная строка (доход↔расход имеют близкие эмбеддинги); несовпадение → null → LLM
  • 4. Мелкие целые (1–99) — это номера примечаний, не финансовые значения. НО правило двустороннее:
    • чистый тег [тек:] + высокий sim (≥0.90) → мелкое целое авторитетно (delimobil «Гудвилл» = 39, sim 0.966 — легитимное значение)
    • средний sim (<0.90) → мелкое целое = признак НЕВЕРНОЙ строки (x5 «Доходы от аренды» = 26 при верном 29 726, sim 0.794) → null
    Порог 0.90 выбран между «это концепт» (0.966) и «близкая, но другая статья» (0.79)
  • 5. Значение — берётся из тега [тек:]/[пред:] (уже разрешил колонку и знак). Позиционный выбор из чисел строки — только если тегов нет
  • 6. Знак из роли метки — «Налог на прибыль» может быть расходом (−) или доходом (+): знак берётся из слов метки «расход» XOR «доход»; если в метке оба («Доход/(Расход)») — роль неоднозначна, знак из тега
  • Итог каскада: строка проходит все фильтры → значение принято как надёжное. Любой фильтр может вернуть null → показатель уходит в LLM (стадия 11). Guards — это не «проверка на всякий случай», а основной механизм превращения косинуса в бухгалтерски осмысленное решение.

    11. LLM-фолбэк

    Только если fast-path вернул null. Модель получает аннотированный текст страницы целиком и возвращает JSON.
    11.1

    Устройство вызова

  • 61 показатель = 61 агент со своим промптом (prompts/individual/agent_*.py, 66 файлов)
  • Модель: deepseek-v4-flash (через DeepSeek API или OpenRouter)
  • Приоритетные страницы → majority-vote k=3 (три чтения, побеждает большинство)
  • Страницы по косинусу → один вызов
  • null после LLM → null-audit (доп. вызов: точно ли значения нет)
  • Спорные → critical-review (перепроверка)
  • deepseek-v4-flash 61 агент k=3 голосование null-audit critical-review
    Ограничение: LLM вызывается ТОЛЬКО на null. Если fast-path вернул неверное значение (не null) — LLM его не перезапишет. Поэтому «расхождения в 163 раза» (rusagro) LLM не чинит.

    12. Постобработка: знаки, агрегаты, сверка

    После всех категорий значения проходят каскад правил. Бухгалтерские инварианты используются как валидация.
    12.1

    Каскад (по порядку)

  • Знаки: correct_sign_from_text (по тексту страницы) → apply_strict_sign (себестоимость/КОА всегда «−») → apply_role_sign (налог по метке) → apply_force_negative (финрасходы/проценты)
  • Сборка расколов: sum_split_sga (Коммерческие + Общехоз/адм = КОА), sum_split_cogs
  • IFRS-граф: отсутствующий агрегат = Σ компонентов (COMB=Σ); ROU-амортизация из стора (resolve_rou_amortization)
  • Кросс-отчётные дубли: strip_duplicate_values (PL vs CF vs BS)
  • Сверка: _cross_statement_feedback (агрегаты из согласующихся частей), _reconcile_indicators (тождества: А=Е+L, CF=ΔДС)
  • Пересчёт сравнительных: detect_restated_comparatives (IAS 8 §42)
  • Пример из прогона: баланс Россетей сошёлся ТОЧНО — Активы 158 878 431 = Капитал 64 818 135 + Обязательства 94 060 296 (извлечено, не вычислено).

    13. Выход

    Результат — JSON на каждый тикер, Excel по тикеру, агрегат по базе.
    extracted.jsoncategories: PL/BS/CF
    extracted_to_excel.py
    <ticker>.xlsx
    aggregate_deliverable.py
    IFRS_EBITDA_DELIVERABLE.xlsx
    // extracted.json — структура
    {
      "pdf": "...",
      "categories": {
        "BS": {
          "period_current_label": "2024",
          "period_previous_label": "2023",
          "unit": "тысячах рублей",
          "indicators": [
            {"name": "Итого активы", "current": 158878431.0,
             "previous": 148754288.0, "source_page": 11}
          ]
        }, "PL": {...}, "CF": {...}
      },
      "restated_comparatives": {...},
      "_meta": {}
    }
  • batch_run.py — массовый прогон: 4 воркера, stagger-старты, фаза-1 GPU-эмбеддинги → prefill, фаза-2 LLM
  • Итог по базе: output_<ticker>/extracted.json + ticker.xlsx + run.log
  • 14. Sidecar-файлы: полный реестр

    Всё, что лежит рядом с PDF после обработки (все — JSON, кроме *_ocr.pdf).
    ФайлСодержимоеПишетЧитает
    *_ocr.pdfPDF с невидимым текстовым слоемOCR-модульpdfplumber, docling
    *_ocr.words.jsonслова: текст + bbox (pt)OCR-модульаннотация, визуализация
    *_docling.jsonтаблицы: ячейки row/col/rowspan/colspandoclingаннотация, tabnorm
    *_layout.jsonзаголовки нот (bbox + текст + score)PP-DocLayoutnote_index
    *_struct.jsonячейки PP-StructureV3pdf_structure_extractаннотация, tabnorm
    *_pages.jsonкеш аннотированных страницпайплайнпайплайн (кеш)
    Кеш-инвалидация по mtime: sidecar свеж, пока новее исходного PDF и скрипта-производителя. Правка pdf_text_restore.py автоматически «протухает» все OCR-кеши.

    15. Ключевые архитектурные решения

    РешениеПочему
    LLM — fallback, не движокДля точных чисел LLM ненадёжен; структурный путь + guards детерминирован и проверяем
    Эмбеддинги — главный ранжировщикДообученный ifrs-embed-v13 понимает доменную семантику («Кредиты и займы полученные…» ≠ «кредиторская»)
    Таблица → тегированный текстДетекторы решают только «какие числа в каких колонках»; дальше — текстовая инженерия
    Инварианты как валидацияА=Е+L, CF=ΔДС, Σ компонент = итог — бухучёт сам проверяет извлечение
    Sidecar-кешиOCR/docling считаются один раз; извлечение перезапускается за секунды
    GPU только у docling4 параллельных воркера не конфликтуют за CUDA; stagger-старты против Blackwell-deadlock
    Двухфазный батчФаза 1: GPU-эмбеддинги одним процессом → prefill; фаза 2: LLM параллельно на CPU-эмбеддингах
    Дообучали точечноifrs-embed-v13 (13 итераций) и cyrillic_v2 (+27 fix, 0 регрессий); остальное — сток + инженерные обходы
    15.1

    А нельзя ли было обойтись Surya OCR + LLM?

  • Короткий ответ: наполовину. Surya мог бы заменить ОДИН слой (распознавание + layout-детекция) — это разумный A/B-кандидат. НО реальный вывод кода surya (surya-ocr-2, прогнан на стр. 24 WTCM) оказался другим, чем ожидалось: никаких слов с confidence нет — страница разбивается на БЛОКИ, и таблица — это один блок
  • Что реально отдаёт surya (замер на WTCM стр. 24): 4 блока — PageHeader, Caption, Table, PageFooter. Таблица = отдельный блок с готовым HTML (<table border="1"> с rowspan/colspan: 14 строк <tr>, 13 ячеек в строке данных). Caption — отдельный блок («(i) Основные допущения…»). Время: ~10 с/стр на 3090 (PNG 150 DPI)
  • Про confidence: per-word confidence в выводе нет в принципе. Блочный confidence есть, но по умолчанию это хардкод 1.0 (в коде: mean_token_prob if not None else 1.0) — реальное значение появляется только если явно запросить logprobs, и это средняя вероятность ТОКЕНОВ блока, а не уверенность по словам
  • Что это значит для пайплайна: да, HTML-таблица — удобный и простой вход (разбор — pandas.read_html, 5 строк; колонки/строки/объединения уже в разметке). Для табличного пути это готовый grid — он честно заменяет связку docling+PP-Structure, не хуже. НО HTML-вход не решает четыре вещи, которые и есть остаток пайплайна:
    • Семантика строк — «26» в ячейке: номер примечания или значение? HTML не отвечает. Это эмбеддинги+guards (x5-кейс, sim 0.794 → null)
    • Проза вне таблиц — часть значений живёт в Text-блоках («на конец периода 121 769 тыс. руб.» в тексте примечания), HTML их не структурирует
    • Многостраничные таблицы — HTML отдаётся на страницу; разорванная таблица ОС на 3 страницы требует склейки (у нас tabnorm_stitch)
    • Сканы и битые слои — 14% страниц (185 из 1316) всё равно требуют OCR-этапа до surya
    Плюс цена перехода: ~10 с/стр surya-2 → 1316 страниц ≈ 3.5 ч GPU и регресс-тест всех 1159 показателей. Геометрия — не главная сложность; семантика («что есть что») — да, и она от формата входа не зависит
  • Но Surya-2 как замена связки paddle+docling+PP-Structure — да: блоки+HTML закрывают 80% задач геометрии (колонки, объединения — вот они, в HTML). Для задач, где нужны ТОЛЬКО таблицы целиком (не точечный поиск строки по метке с guards) — это сильный и простой вариант
  • LLM-центричный разбор имеет смысл только для разовых задач на 5-10 документов, где цена ошибки низкая
  • Замер сделан на живой системе: python -m surya.scripts.ocr_text (код surya, vLLM-бэкенд, surya-ocr-2) на рендере стр. 24 WTCM. Вывод: 4 блока, Table с HTML (14 строк, rowspan/colspan), все conf = 1.0 (хардкод без logprobs). Вывод: гибрид «детерминированный каркас + LLM только на пустотах» — уже эмпирически найденный оптимум (87.4% при полной проверяемости); surya-2 стоит проверить как замену OCR+геометрии, но не как замену guards/эмбеддингов.