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

89 lines
29 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.** Заказ владельца после находки: ingest-слой границ глав захардкожен под CJK-форму, выведенную из одной стенд-книги (не-CJK txt молча становится одной главой; epub режется по spine с игнором оглавления; print-TOC порождает главы-огрызки). Источники: веб-ресёрч с URL-грунтовкой (§1§2) · панель двух независимых моделей (§5) · карта по боевому коду (§6). **Статус: фактура и дизайн-синтез готовы; дизайн владельцем НЕ ратифицирован — стройка не санкционирована.** Носители работ — строки бэклога 160162; жизненный цикл дерева (§3а) — верхнеуровневое решение владельца 09.08, детали обстучать при реализации.
## §1. Три ключевых ответа
1. **Не-эвристического алгоритма для голого txt не существует, и это доказано числами:** Chapter Captor (EMNLP 2020, 9126 романов Gutenberg) — распознавание ЗАГОЛОВКОВ гибридом regex+BERT даёт F1=0.77, а предсказание границ БЕЗ заголовков (семантическая сегментация) — F1=0.453 [arxiv.org/abs/2011.04163]. Главы — авторская разметка, а не тематический сдвиг. Все зрелые инструменты (Calibre, chapterize, kaf-cli, AozoraEpub3) — каскад «статистика формата строк → языковые паттерны → пороги правдоподобия».
2. **У epub оглавление есть по стандарту и обязательно.** EPUB 3: публикация «MUST contain an EPUB navigation document» с toc nav — иерархия заголовков + ссылки файл#якорь [w3.org/TR/epub-33/#sec-nav-toc]. EPUB 2: обязательный NCX (вложенность томов/глав; 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. **Print-TOC — не помеха, а источник.** Устоявшееся направление (ICDAR Book Structure Extraction 20092013): детект TOC-блока → парсинг → линковка в тело матчем заголовков — границы плюс канонические названия даром; TOC-гейты дёшевы (кандидаты ближе 4 строк = блок оглавления).
## §2. Несущие факты по слоям
**EPUB:** резать по 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 обязателен) → epub:type/DPUB-ARIA в контенте (добровольный — сигнал, не гарантия). Однофайловый epub и битый nav → фолбэк на txt-эвристики.
**TXT (Calibre как эталон зрелости):** (1) статистический детект формата абзацев; (2) списки словесных паттернов — у Calibre только en+de(!), CJK-миры держат свои инструменты: kaf-cli zh «第.{1,8}章» + том «第…[卷部]», AozoraEpub3 ja — 話/章/篇/部/節/幕/プロローグ/序章/終章/間章; (3) пороги правдоподобия: min_chapters=ceil(wordcount/7000) ≤ hits < 150, заголовок 35 симв., 3 глав; (4) сцены «* * *» отдельный класс, не главы; (5) конвенция Gutenberg «4 пустые строки перед главой» пустострочные раны как вторичный сигнал [W3C WCAG T3]. **Готовой мультиязычной библиотеки паттернов-как-данных не существует** собирать свою в структурный пак из проверенных списков (сид: kaf-cli + AozoraEpub3 + Calibre + chapterize; regex-набор Chapter Captor сид en); прецедент конфиг-схемы ParserConfig oomol-lab (chapter/volume/section_patterns).
**Иерархия:** том/часть над главой отдельный уровень паттернов у всех зрелых инструментов; плоский список ломается на «Том 2, Глава 1» (нумерация перезапускается) в модели структуры нужен уровень volume/part.
**Место LLM:** полная LLM-разметка книги (LumberChunker, EMNLP 2024) дороже/медленнее по признанию авторов, цена линейна от объёма, недетерминизм ломает снапшоты как основной путь отвергнута. Доказанный в проде паттерн (LILAC, FSE'24, лог-парсинг): **LLM один раз ВЫВОДИТ шаблон, дальше режет детерминированный код**; перенос на книги естественен (вызов на семплах строк-кандидатов шаблон книги в снапшот перепрогоны $0). Для книг такой паттерн не опубликован.
## §3. Рекомендуемый каскад (предложение оркестратора, НЕ ратифицировано)
LLM никогда не режет сама; по цене источника:
1. **Структурные источники ($0, без эвристик):** epub nav NCX fb2 section/title; скрейп-epub (WebToEpub/FanFicFare) границы точны по построению, доверять. Названия на языке оригинала оттуда же.
2. **TXT:** print-TOC-блок исключить из границ + оракул имён; языко-независимый индуктор (кластеризация скелетов строк + скоринг: изоляция · монотонная нумерация · коридор hits/wordcount) + словари маркеров per-language как ДАННЫЕ; конкурирующие схемы-кандидаты.
3. **Fallback:** один дешёвый LLM-вызов выводит шаблон книги; шаблон артефакт снапшота.
4. **Всегда:** громкие WARN вместо молчаливой деградации 1 глава на 20 МБ», «скачок нумерации», «глава-огрызок»).
**Два жёстких требования к дизайну (владелец, 09.08):** модель «что бывает заголовком» выводится из КОРПУСА разнообразных книг, не из стенд-книги, и приёмка детекта прогоном по корпусу (полигон); схема данных проектируется от общего кейса, а не наращиванием заплаток (исторически слой выпал из карты общности: маркеры ушли в данные, форма шаблона осталась в Go). **Цена итераций:** правки детекта двигают нарезку реснапшот-класс на прогнанных книгах строить один раз, ДО открытия интейка книг, пока платная книга одна.
### §3а. Жизненный цикл дерева глав (решение владельца 09.08, верхнеуровнево; детали обстучать при реализации)
1. **Парсинг (загрузка):** дерево показывается сразу черновая структура с пометкой «может быть неточно», оригинальные названия + служебный рендер «Глава N» ($0, локаль интерфейса). Открытая продуктовая развилка: владелец предлагал давать «примерный перевод названий» уже на этой стадии это платный вызов ДО старта прогона (деньги начинаются с загрузки, а не с явного запуска); рекомендация оркестратора перевод со старта прогона (п.2), решение при ратификации дизайна.
2. **Старт прогона:** мини-сессия названий 5а; требует роли title Этап 1) даёт провизорный перевод дерева в первые секунды прогона.
3. **Черновая волна:** структура ЖИВАЯ in-band метки черновика (Этап 2; до его постройки стадия пропускается) корректируют её по ходу: число глав может измениться против эвристики недоразмеченное добавится, ложное удалится. Freeze на этой стадии относится к НАРЕЗКЕ/деньгам, не к виду: якорей читателей ещё нет, вид-плоскость легально ревизуется.
4. **Подпись банка (майнинг-стоп):** перегенерация дерева ревизия структуры по накопленным меткам + адресная ревизия названий по подписанному банку 5а фаза 2). С этой точки структура затвердевает, дерево показывает поглавный прогресс перевода.
## §4. Что НЕ брать
Тематическая сегментация как основа (F1=0.453) максимум tie-breaker · полная LLM-разметка (цена/недетерминизм) · вебновелльные скрейперы как образец детекта (у них глава = страница сайта) · универсальный код без языковых данных (ни один инструмент так не решил).
## §5. Панель двух независимых дизайнеров (Fable-5 · Opus-5) — конвергенция
Обе модели проектировали алгоритм с нуля по одинаковому промту без наводок (только задача и свойства движка; репо и этот док им были недоступны; веб только для фактов). **Сошлись на ~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 скрейпов массовый случай); правило маржи между гипотезами; неоднозначность = первоклассный громкий исход с выбором пользователя.
5. Скоринг-вектор: правдоподобие количества · распределение длин (runt/whale) · монотонность нумерации · согласие источников · доминантность шаблона · headSpread (границы сгрудились в начале = TOC). Три вердикта: OK / DEGRADED-с-badge / FAILED.
6. **Честный FAILED**: псевдо-главы не выдумываются никогда; дерево показывает «структура не распознана» + технические «Фрагменты» (визуально НЕ главы); перевод НЕ блокируется. Выходы: выбор из отвергнутых кластеров одним кликом · ручной редактор структуры (правки = отдельный слой) · opt-in LLM-классификатор кандидатов со сметой.
7. **Инвариант сохранения символов**: Σ длин глав + служебного == длине тела, точно; нарушение = баг движка, платные вызовы не стартуют.
8. **Развязка денег и структуры** (несущее, оба независимо): оплата/память ключуются контент-хешами СЕГМЕНТОВ, глава группировка-вид; пере-нарезка = перегруппировка оплаченного. Structure freeze после старта перевода (нарезка); ревизия только явной миграцией с отчётом «что осиротеет, что перекупится, почём» ДО применения.
9. Якоря читателя на хешах абзацев; id главы контент-производный (хеш первого окна тела+титула правка в глубине главы id не двигает); миграционная карта same/moved/split/merged/gone/new, осиротевшее показывается, не выбрасывается.
10. LLM в детекте только opt-in классификатор ПОВЕРХ намайненных кандидатов, результат замораживается в снапшоте (replay, не повторный вызов).
11. Названия: лестница источников с провенансом; отсутствующее служебная метка `generated:true` (локаль интерфейса), НЕ фабрикация; **номер не переводится, а перерендеривается** ($0); перевод имён батч-роль title 5а); канон названия сверяется $0-гейтом с заголовком в переведённом теле.
12. Калибровочный корпус из УРОДЛИВЫХ реальных книг (скрейпы/самиздат/конверсии, все скрипты; хеши+смещения, не тексты) + CI-запрет регрессий + запрет подгонки порогов без добавления книги в корпус.
13. Детерминизм механики: целочисленные баллы, сортированные обходы, фиксированные тай-брейки, побайтовый golden на structure.json.
Расхождения только в глубине: Opus типизировал провалы (отдельно «гибрид: половина книги размечена» и «nav и NCX согласны, но оба неверны согласие правота»), добавил детект бойлерплейта скрейпов; Fable детальнее проработал пользовательский редактор структуры. In-band-разметку моделью в потоке перевода не предложил ни один (оба заменили модель-сейфгард бесплатным кросс-аудитом) она остаётся ТРЕТЬИМ эшелоном (идея владельца): для книг без кандидатов, для коррекции структуры по ходу черновой волны 3а п.3) и как канал названий; её выход проходит тот же валидатор и не двигает замороженную нарезку.
### §5а. Мини-сессия названий (роль title; уточнено с владельцем 09.08)
Названия переводит НЕ волна тела, а отдельная батч-роль `title` (механика InternalCall, чекпойнт/реплей/телеметрия наследуются): **редакторский слот, не черновой** объём копеечный (единицы тысяч токенов на книгу), видимость максимальная; **инъекция банка той же механикой** `memory.Select` спойлер-окна работают естественно (у названия есть номер главы). Синхронизация с банком **двухфазная** (полный банк существует только после майнинг-стопа и подписи): фаза 1 на старте прогона с сид-банком названия провизорны; фаза 2 после подписи банка адресная ревизия только названий, где термы разошлись (дифф по попаданиям). Волны потом переводят подзаголовок в прозе тела $0-канон-гейт сверяет его с названием дерева, расхождение = громкий флаг. Номер рендерится всегда бесплатно.
## §6. Рекомендации реализации, обстучанные о боевой код (карта по рабочему дереву с незакоммиченным паком блокеров)
### Жёсткие правды кода
1. **Деньги ключуются ПОЗИЦИЯМИ, не контентом.** RequestHash несёт плотный номер главы и ChunkIdx (render.go:292-317), chunk_status ищется строго по (book, chapter, chunk_idx, stage) (migrate.go:125); контент-хеш `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), параграф легально пересекает чанки «якоря на хешах абзацев» 5 п.9) целиком новая плоскость; ближний эквивалент сегодня edit-юнит (ПТ-21) + chapter-ID манифеста.
### Что переиспользуется (строить не надо)
Манифест-сайдкар пака блокеров = **~60% механики structure.json**: версионируемый документ с ключом годности и cutTag, стейл-детект с фолбэком, атомарная запись (artifact.go), $0-команда tmctl manifest, шов read-моделей status/export; расширение содержимого двигает ТОЛЬКО файл (в снапшот не входит). · projectRebill/checkRebillConsent = готовый отчёт «что перекупится и почём» ДО применения. · matchHeaderLine + parseSectionNumeral + HeadingRule = готовая грамматика с CJK-читателем числительных. · splitTextChapters = готовый первый паттерн-пак индуктора. · Лестница кодировок, zip/OPF/xhtml-транспорт, 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).
### Этапность (носители — строки бэклога 160162; ратификация дизайна — владелец)
- **Этап 0 (строка 160) $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 (строка 161) «большой перекрой», единственное resnapshot-окно, пока платная книга одна:** IR-слой + формато-адаптеры (epub nav/NCX + фильтры linear/properties сейчас opfPackage их не парсит, ingest.go:438-447; fb2) + индуктор с валидаторами и вердиктами + третий версионный план паттернов; развязка чанкера от глав (главы = группировка-вид); спойлер-окна банка с ординалов на chapter-ID (bank-only move репиннимый класс); контент-адресуемый resume (долг D15.2) чтобы будущие ревизии структуры перестали стоить хвост книги; формула Chapter.ID «окно» до материализации глав у пользователей; стадия title 5а; аддитивный фолд стадии двигает снапшот в это же окно); аддитивное расширение контракта 14 (fragment-тип, вердикт, badge минорный бамп). Всё одним согласованным resnapshot стенд-книги с консент-отчётом.
- **Этап 2 (строка 162) после:** in-band метки глав в потоке перевода (третий эшелон + коррекция структуры по ходу черновой волны, §3а; промпт-сдвиг волны) + канон-гейт названий ($0) + миграционные карты якорей + калибровочный корпус в CI (полигон).
### Граница доверия LLM ↔ статика
Языковые данные руками не пишутся никогда: сид проверенные списки зрелых инструментов (zh/ja/en/de готовы); на новом языке/книге с низкой уверенностью универсальных сигналов один дешёвый LLM-вызов ВЫВОДИТ шаблон книги, шаблон проходит тот же статический валидатор, замораживается в снапшот, накопленное абсорбируется в паттерн-пак. LLM генератор и арбитр данных; статика исполнитель, валидатор (сохранение символов, монотонность, коридоры валидировать LLM-выход может только она) и масштабатор. Полный LLM-проход по книге отвергнут 4).