textmachine/docs/research/27-chapter-detection.md

31 KiB
Raw Blame History

27. Определение глав в загружаемых книгах: стандарты · практика · алгоритмы

Оркестратор №15, 09.08.2026. Веб-ресёрч по заказу владельца 09.08 (контекст: фактограф приёмки пака блокеров показал, что ingest-слой границ глав захардкожен под CJK-форму, выведенную из одной стенд-книги, — не-CJK txt молча становится одной главой, epub режется по spine с игнором оглавления, print-TOC порождает главы-огрызки). Три веб-агента: стандарты форматов · практика инструментов · алгоритмы; каждый факт с URL. Статус: ФАКТУРА СОБРАНА; дизайн-решение владельцем НЕ принято — этот док не ратифицирует стройку. Носители работ — строки бэклога (заводятся лендингом пака блокеров).

§1. Ответы на вопросы владельца

  1. «Есть ли алгоритмы, умеющие парсить главы, или всё эвристика?» Надёжного не-эвристического алгоритма для голого txt НЕ существует, и это доказано числами: Chapter Captor (EMNLP 2020, 9126 романов Gutenberg) — распознавание ЗАГОЛОВКОВ гибридом regex+BERT даёт F1=0.77, а предсказание границ БЕЗ заголовков (семантическая сегментация) — F1=0.453 [arxiv.org/abs/2011.04163]. Главы — авторская разметка, а не тематический сдвиг; TextTiling/C99/BERT-сегментация спроектированы под другое и как основа непригодны. Все зрелые инструменты (Calibre, chapterize, kaf-cli, AozoraEpub3) — каскад «статистика формата строк → языковые паттерны → структурные fallback'и → пороги правдоподобия».
  2. «У epub по стандарту оглавления нет?» ЕСТЬ И ОБЯЗАТЕЛЬНО. EPUB 3: публикация «MUST contain an EPUB navigation document» с toc nav — иерархия ol/li/a с заголовками и ссылками файл#якорь [w3.org/TR/epub-33/#sec-nav-toc]. EPUB 2: обязательный NCX (navMap/navPoint, вложенность томов/глав, navLabel+ с xml:lang — единственный стандартный носитель МУЛЬТИЯЗЫЧНЫХ названий глав) [idpf.org/epub/20/spec/OPF_2.0.1_draft.htm]. FB2 (жив, критичен для ru-рынка): дерево глав = сама разметка body/section с рекурсией и title [github.com/gribuser/fb2]. То есть для epub/fb2 границы И НАЗВАНИЯ глав достаются из стандарта вообще без эвристик — наш текущий разрез «по spine с игнором nav» выбрасывает гарантированную спекой структуру.
  3. «Что с печатным оглавлением?» Отдельное устоявшееся направление (ICDAR Book Structure Extraction 20092013): детект TOC-блока → парсинг записей → линковка в тело матчем заголовков. Print-TOC — не помеха, а бесплатный источник границ и КАНОНИЧЕСКИХ названий; TOC-гейты дёшевы (chapterize: кандидаты ближе 4 строк = блок оглавления).

§2. Несущие факты по слоям

EPUB (уточнения сверх §1): резать по spine неверно ПО СТАНДАРТУ — якоря nav легально ведут внутрь файла (несколько глав в одном xhtml) и глава легально живёт в нескольких файлах; правильный алгоритм: поток = spine linear="yes", границы = якоря nav/NCX, спроецированные на поток. Порядку/полноте nav верить нельзя (с EPUB 3.3 порядок — SHOULD, полнота не требовалась никогда) — нормализация: сортировка целей по позиции в spine, непокрытые документы прикреплять к предыдущей главе. Служебные страницы фильтруются каскадом штатных сигналов: manifest properties (nav, cover-image) → spine linear="no" → landmarks (epub:type обязателен: cover/titlepage/toc/bodymatter) → epub:type/DPUB-ARIA в контенте (добровольный — сигнал, не гарантия). Однофайловый epub и битый nav → фолбэк на txt-эвристики.

TXT — состояние искусства (Calibre как эталон): (1) статистический детект формата абзацев (line histogram, доли пустых/отступных строк); (2) списки словесных паттернов — у Calibre только en+de в коде(!), CJK-миры держат свои инструменты: kaf-cli zh «第.{1,8}章» + том «第…[卷部]», AozoraEpub3 ja — богатый список 話/章/篇/部/節/幕/プロローグ/序章/終章/間章; (3) пороги правдоподобия: схема принимается только при min_chapters=ceil(wordcount/7000) ≤ hits < 150, заголовок ≤35 симв. (kaf-cli), ≥3 глав (chapterize); (4) сцены «* * *» — отдельный класс, не главы; (5) конвенция Gutenberg: 4 пустые строки перед главой — пустострочные раны как вторичный сигнал [pgdp.net DP Formatting Guidelines; W3C WCAG T3]. Готовой мультиязычной библиотеки паттернов-как-данных не существует — собирать свою в langpack из проверенных списков этих инструментов (сид: kaf-cli+AozoraEpub3+Calibre+chapterize; regex-набор Chapter Captor — сид для en); прецедент конфиг-схемы — ParserConfig oomol-lab (chapter_patterns/volume_patterns/section_patterns).

Иерархия: том/часть над главой — отдельный уровень паттернов у всех зрелых инструментов; плоский список ломается на «Том 2, Глава 1» (нумерация перезапускается) — в модель структуры нужен уровень volume/part, иначе якоря и банк едут на многотомниках.

LLM-место: полная LLM-разметка книги (LumberChunker, EMNLP 2024) — дороже/медленнее по признанию авторов, цена линейна от объёма, недетерминизм ломает снапшоты — ОТВЕРГНУТЬ как основной путь. Доказанный в проде паттерн (LILAC FSE'24, лог-парсинг): LLM один раз ВЫВОДИТ шаблон, дальше режет детерминированный код — шаблонов на порядки меньше экземпляров; перенос на книги естественен (один дешёвый вызов на семплах строк-кандидатов → regex книги → в снапшот → перепрогоны $0 и воспроизводимы). Для книг такой паттерн не опубликован — будем первыми.

§3. Рекомендуемый класс решения (предложение оркестратора, НЕ ратифицировано)

Каскад по цене источника, LLM никогда не режет сама:

  1. Структурные источники ($0, без эвристик): epub nav → NCX → fb2 section/title; скрейп-epub (WebToEpub/FanFicFare и т.п.) — границы точны по построению, доверять. Названия глав на языке оригинала — из тех же источников (закрывает и вопрос heading/К-3).
  2. TXT: детект print-TOC блока (плотность кандидатов) → использовать его как источник имён и сверки; языко-НЕзависимый статистический скорер строки-заголовка (короткая · изолирована пустыми строками · монотонная нумерация · повторяющийся префикс-шаблон · равномерность по файлу · коридор hits/wordcount) + словари маркеров per-language как ДАННЫЕ langpack; несколько конкурирующих схем-кандидатов, выбор по скору.
  3. Fallback: один дешёвый LLM-вызов выводит шаблон книги, шаблон — артефакт снапшота (LILAC-паттерн).
  4. Всегда: громкие WARN вместо молчаливой деградации («1 глава на 20 МБ», «скачок нумерации», «глава-огрызок»).

Требование к дизайну (урок владельца 09.08): модель «что бывает заголовком» выводится из КОРПУСА разнообразных книг (языки·авторы·форматы), не из стенд-книги; приёмка детекта — прогоном по корпусу с замером точности границ (полигон). Схема данных проектируется от общего кейса, а не наращиванием заплаток к CJK-форме (история вопроса: пак-2 завёл CJK-сплит до канона общности, фазы общности вынесли маркеры в данные, но форма шаблона осталась в Go и слой выпал из карты общности — D39.64 честно не включал его в критерий).

Цена итераций: правки детекта двигают нарезку → снапшот → реснапшот-класс на прогнанных книгах. Проектировать один раз от корпуса и строить ДО открытия интейка книг пользователям; сейчас единственная платная книга — стенд, окно дешёвое.

§4. Что НЕ брать

Тематическая сегментация как основа (F1=0.453) — максимум tie-breaker · полная LLM-разметка (цена/недетерминизм) · вебновелльные скрейперы как образец детекта (у них глава = страница сайта, задачи нет) · универсальный код без языковых данных (ни один инструмент так не решил).

§5. Панель двух независимых дизайнеров (Fable-5 · Opus-5, 09.08) — конвергенция

По заказу владельца обе модели независимо проектировали алгоритм с нуля: одинаковый промт без наводок (только задача и свойства движка), запрет на чтение репо (этот док им был недоступен), веб разрешён для фактов. Полные дизайны — журнал wf_640b4366-b81. Сошлись на ~13 несущих решениях, и все совместимы с §1§3:

  1. Один общий движок + формато-адаптеры → единое IR (поток блоков с атрибутами); языковое — ТОЛЬКО данными (грамматика шаблонов {marker}{numeral}{sep}{name}, читатели числительных, лексиконы, ВСЕ пороги).
  2. Индукция шаблона вместо построчных порогов: кандидаты кластеризуются по скелету шаблона, оценивается КЛАСТЕР (доминирующее семейство + возрастающий ряд номеров); одиночное «Глава» в диалоге не примыкает к кластеру из 300 — отсекается по построению.
  3. EPUB: nav/NCX приоритетно с валидацией; FB2: секции с проверкой вырожденности; TXT: индуктор. Print-TOC: детект плотного региона → исключить из границ + использовать как ОРАКУЛ названий/количества (LCS-выравнивание с телом — сильнейший валидатор).
  4. Кросс-аудит всегда: текстовый индуктор гоняется и на EPUB/FB2 как независимая $0-гипотеза против деклараций формата (битые TOC скрейпов — массовый случай); Opus: правило маржи между гипотезами, неоднозначность = первоклассный громкий исход с выбором пользователя.
  5. Скоринг-вектор структуры: правдоподобие количества (коридор от объёма) · распределение длин (runt/whale) · монотонность нумерации · согласие источников · доминантность шаблона · headSpread (границы сгрудились в начале = TOC). Три вердикта OK / DEGRADED-с-badge / FAILED.
  6. Честный FAILED: псевдо-главы не выдумываются никогда; дерево показывает «структура не распознана» + технические «Фрагменты» (визуально НЕ главы, серые, отдельная нумерация); перевод НЕ блокируется. Выходы: выбор из отвергнутых кластеров одним кликом · ручной редактор структуры (правки = слой в снапшоте) · opt-in LLM-классификатор кандидатов со сметой.
  7. Инвариант сохранения символов: Σ длин глав+служебного == длине тела, точно; нарушение = баг движка, платные вызовы не стартуют.
  8. Развязка денег и структуры (оба, независимо — несущее): оплата/память ключуются контент-хешами СЕГМЕНТОВ (абзац/группа), глава — группировка-вид; пере-нарезка = перегруппировка оплаченного, перекупка только сегментов, разрезанных новой границей. Structure freeze после старта перевода; ревизия — только явной миграцией с отчётом «что осиротеет, что перекупится, почём» ДО применения.
  9. Якоря читателя — на хешах абзацев (переживают смену границ по построению); id главы — контент-производный (Opus: хеш первого окна тела+титула — правка в глубине главы id не двигает); миграционная карта same/moved/split/merged/gone/new, осиротевшее показывается, не выбрасывается.
  10. LLM в детекте — только opt-in классификатор ПОВЕРХ намайненных кандидатов, ограниченные токены, результат замораживается в снапшоте (replay, не повторный вызов); свободное «LLM режет книгу» — запрещено обоими.
  11. Названия: извлечение по лестнице источников с провенансом; отсутствующее — служебная метка generated:true на локали интерфейса, НЕ фабрикация; номер не переводится, а перерендеривается ($0, «第十二章»→«Глава 12»); перевод названий — ранний дешёвый батч (единицы тысяч токенов на книгу) как обычные сегменты с ролью title, провизорные до стабилизации глоссария, адресная ревизия диффом по термам; канон названия сверяется $0-гейтом с заголовком в переведённом теле.
  12. Калибровочный корпус из УРОДЛИВЫХ реальных книг (скрейпы/самиздат/конверсии, все скрипты; хеши+смещения границ, не тексты) + CI-запрет регрессий + запрет подгонки порогов без добавления книги в корпус — оба дословно воспроизвели наш урок «валидировать на корпусе».
  13. Детерминизм механики: целочисленные баллы, сортированные обходы, фиксированные тай-брейки, побайтовый golden на structure.json.

Расхождения — только в глубине, не в направлении: Opus типизировал провалы (F1F7, отдельно «гибрид: половина книги размечена» и «nav и NCX согласны, но оба неверны — agreement ≠ правота»), добавил детект бойлерплейта скрейпов и хрупкость content-hash id на шаблонных шапках; Fable — batch-LLM-ассист и пользовательский редактор структуры детальнее. НИ ОДИН не предложил in-band-разметку глав моделью в потоке перевода (идея владельца 09.08): оба заменили «модель-сейфгард» бесплатным кросс-аудитом. Синтез оркестратора: in-band-разметка остаётся ТРЕТЬИМ эшелоном для книг, где кандидатов нет вовсе (заголовков стилистически нет — оба дизайна честно отправляют такие в ручной редактор), и каналом переведённых названий; её выход обязан проходить тот же валидатор, что и статические гипотезы, и не двигать замороженную нарезку (вид-плоскость).

Интеграционный вывод для текущего движка (вопрос владельца «код полагался на ранние точные границы»): оба дизайна требуют ровно того разделения, что названо в диалоге 09.08 — плоскость нарезки/оплаты (заморожена, ключи по контенту сегментов; сегодня это чанки+чекпойнты — уже близко) отделяется от плоскости структуры (версионируемый артефакт банковского типа со своим снапшотом, ревизиями, миграционными картами); полное принятие ревизии структуры в нарезку — только через существующий --resnapshot. Главные точки расклейки текущего кода: редакторские юниты группируются в пределах главы (граница формирует платные запросы) · id главы = хеш всего текста главы · банковские since/until по номерам глав. Это масштаб крупного пака; дизайн-ратификация — отдельным решением владельца. Детальная карта — §6.

§6. Рекомендации реализации, обстучанные о боевой код (два картографа по рабочему дереву, 09.08; все file:line — рабочее дерево с незакоммиченным паком блокеров)

Жёсткие правды кода, которые дизайн обязан уважать

  1. Деньги сегодня ключуются ПОЗИЦИЯМИ, не контентом. RequestHash несёт плотный номер главы и ChunkIdx (render.go:292-317), chunk_status ищется строго по (book, chapter, chunk_idx, stage) (migrate.go:125, stagerun.go:84); контент-хеш msgsContentHash существует, но только как guard резюме, не как ключ поиска. Сдвиг ОДНОЙ границы главы перенумеровывает хвост книги и промахивает все его чекпойнты — перекупка хвоста при неизменных байтах.
  2. Любой новый детектор границ = bump chunkerVersion → движение обоих волновых снапшотов → moveOther → $0-репин НЕ работает вовсе (repin.go:47-91): перекупаются обе волны на 100%. Это признанный отложенный долг D15.2 — snapshot.go:53-64 прямо: «content-addressed reuse … must be designed». Отсюда несущее следствие: итерации детекта до развязки денег стоят полный прогон книги; единственное дешёвое окно — пока платная книга одна (стенд).
  3. Нарезка сама зависит от глав: паковка параграфов и edit-юниты рестартуют на границе главы (chunker.go:120-145, 330-397), инжекция банка зависит от номера главы (спойлер-окна since/until в плотных ординалах — memory.go:642-650, sticky-reset на границе — wave.go:82-95). «Структура = вид» недостижима одной перекраской ключей — нужна развязка чанкера от глав, а это само по себе одноразовый resnapshot.
  4. Дыра Р6 (найдена картографом): ревизия структуры, не двигающая снапшот (ручной редактор, структура-как-данные), сегодня перекупила бы хвост МОЛЧА — projectRebill пропускает строки с совпавшим снапшотом, контент/позиционная ось невидима консент-гейту (rebill.go:32-35, 144-146). Закрывается дёшево read-path-расширением projectRebill — и это ровно миграционный отчёт §5 п.8.
  5. Паттерн-паки структуры НЕЛЬЗЯ класть в существующие планы данных: pack.Version() и EmbeddedVersion фолдятся в волновой снапшот (snapshot.go:441-442) — файл паттернов в langpack инвалидировал бы переводческие снапшоты пары, в embedded — всех книг. Нужен ТРЕТИЙ версионный план (structure-pack), фолдящийся только в manifestKey (тот пересобирается за $0). Ловушка рядом: само ДОБАВЛЕНИЕ нового поля в снапшот ломает репин всех старых строк (unknown field = moveOther, repin.go:70-84) — одноразовая цена, планировать в то же окно.
  6. Параграф-уровня в движке нет нигде (параграфы транзиентны, chunker.go:458-466), и параграф легально пересекает чанки (oversized режется по предложениям) — «якоря на хешах абзацев» из §5 п.9 — целиком новая плоскость; ближний эквивалент сегодня — edit-юнит (ПТ-21) + chapter-ID манифеста.

Что переиспользуется (строить НЕ надо)

Манифест-сайдкар пака блокеров = ~60% механики structure.json: версионируемый документ с ключом годности и cutTag, стейл-детект с фолбэком «никогда не неправильный ответ», атомарная запись (artifact.go), $0-команда tmctl manifest, шов read-моделей readModelChunks → status/export. Расширение содержимого двигает ТОЛЬКО файл (в снапшот не входит). · projectRebill/checkRebillConsent = готовый механизм отчёта «что перекупится и почём» ДО применения. · matchHeaderLine + parseSectionNumeral + HeadingRule = готовая грамматика {marker}{numeral}{unit}[sep]{name} с CJK-читателем числительных. · splitTextChapters = готовый ПЕРВЫЙ паттерн-пак индуктора (частотное доминирование ≥2 + precision-гарды). · Лестница кодировок, zip/OPF/xhtml-транспорт epub, ruby-захват — как есть. · ApplyHeading = $0-канал показа названий (не в чекпойнтах). · Терминологист-батчи на синтетической оси + InternalCall = прецедент для батч-перевода названий. · WaveSignatureStop (exit 3) = прецедент «неоднозначность — громкий исход с выбором пользователя». · Контракт 14 и платформа УЖЕ готовы: Chapter.id opaque, number «не ключ», heading nullable «из данных книги», эпоха курсора = generation of the manifest, resync_required; таблица chapters платформы под opaque id построена (00002_readmodel.sql:91-106).

Этапность (рекомендация оркестратора; ратификация — владелец)

  • Этап 0 — $0, wire-нейтрально, можно немедленно: titleRaw в манифест (subtitle уже добывается в matchHeaderLine и выбрасывается — только пронести) + provenance у heading (rendered/data) + тип единицы (chapter/fragment) + вердикт структуры в документ; бамп manifestVersion. Закрытие дыры Р6 в projectRebill. Слой пользовательских правок — ОТДЕЛЬНЫЙ сайдкар (в манифест нельзя: он unconditionally перестраивается, hand-edit=invalid по конструкции — manifest.go:36-38, 456-459).
  • Этап 1 — «большой перекрой», единственное resnapshot-окно, пока платная книга одна: IR-слой + формато-адаптеры (epub nav/NCX + фильтры linear/properties — сейчас opfPackage их вообще не парсит, ingest.go:438-447; fb2) + индуктор с валидаторами и вердиктами + третий версионный план паттернов; развязка чанкера от глав (паковка по сегментам, главы = группировка-вид); спойлер-окна банка с ординалов на chapter-ID (bank-only move — репиннимый класс); контент-адресуемый resume (долг D15.2) — чтобы БУДУЩИЕ ревизии структуры перестали стоить хвост книги; смена формулы Chapter.ID на «окно» (§5 п.9) — до материализации глав у пользователей; новая стадия title (батч-перевод названий) — аддитивный фолд стадии двигает снапшот, вписать в это же окно. Всё — одним согласованным resnapshot стенд-книги с консент-отчётом.
  • Этап 2 — после: in-band метки глав в потоке перевода (третий эшелон, промпт-сдвиг волны) + канон-гейт названий ($0) + миграционные карты якорей (same/moved/split/merged/gone/new) + корпус в CI.

Граница доверия LLM ↔ статика (ответ на наброс владельца 09.08)

Языковые данные руками не пишутся никогда: сид — проверенные списки зрелых инструментов (zh/ja/en/de готовы); на новом языке/книге с низкой уверенностью универсальных сигналов — один дешёвый LLM-вызов ВЫВОДИТ шаблон книги (распознавание — сила LLM), шаблон проходит тот же статический валидатор, замораживается в снапшот, накопленное абсорбируется в паттерн-пак. LLM — генератор и арбитр данных; статика — исполнитель, валидатор (сохранение символов, монотонность, коридоры — валидировать LLM-выход может только она) и масштабатор. Полный LLM-проход по книге отвергнут (§4); in-band-разметка в потоке перевода — третий эшелон для книг без кандидатов и канал переведённых названий.