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

82 lines
31 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.

# 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-разметка в потоке перевода третий эшелон для книг без кандидатов и канал переведённых названий.