diff --git a/backend/docs/CHAPTER_STRUCTURE_REPORT.md b/backend/docs/CHAPTER_STRUCTURE_REPORT.md index b69efee0..cf2ac38a 100644 --- a/backend/docs/CHAPTER_STRUCTURE_REPORT.md +++ b/backend/docs/CHAPTER_STRUCTURE_REPORT.md @@ -12,8 +12,12 @@ | | команда | EXIT | пакетов | FAIL | линтер | скипы | |---|---|---|---|---|---|---| -| до правок | `make battery` | 0 | 19 | 0 | 0 issues | **3** | -| после | `make battery` | 0 | 19 | 0 | 0 issues | **3** | +| до правок | `make battery` (одной командой) | 0 | 19 | 0 | 0 issues | **3** | +| после | ⚠ ПО СТАДИЯМ — см. §11 | 0 | 19 | 0 | 0 issues | **3** | + +⚠ **Строка «после» — НЕ `make battery`.** Одной командой она не прошла: система дважды убила её по нехватке +памяти. Прогон сделан по стадиям, команды перечислены в §11. Первая редакция этой таблицы писала `make +battery` в обеих строках и противоречила §11 того же отчёта. Скипы поимённо и в обоих прогонах те же: `TestMinerFullBookParity` (корпусный, снимается `battery-stand`), `TestHelperEventsRun`, `TestHelperKillLoop` (хелперы подпроцессов). **Новых скипов не появилось** — это @@ -21,7 +25,7 @@ ### Каталог мутаций -201 → **205 записей**. Шесть прогнаны исполнением на КОПИИ дерева (`~/tm-mut-0509`, `GOFLAGS=-buildvcs=false`), +201 → **211 записей** (первая редакция говорила 205 — это было состояние ПЕРВОГО круга, до шести записей дофикса). Шесть прогнаны исполнением на КОПИИ дерева (`~/tm-mut-0509`, `GOFLAGS=-buildvcs=false`), **6 RED, 0 неожиданных исходов**: | запись | покрасневший тест | @@ -52,7 +56,8 @@ ### Счета формата -`form feeds ignored`, `documents attached`, `documents excluded`, `toc unresolved`, `targets collapsed` живут в `chunk.Document`, +`form feeds ignored`, `documents attached`, `documents excluded`, `toc unresolved`, `targets collapsed`, +`service roles inside documents` — ШЕСТЬ счётов — живут в `chunk.Document`, уезжают в WARN (`events.go`, `ingestSource`) и **НЕ на провод** — потребителя у них сегодня нет. --- @@ -197,13 +202,13 @@ unit-id главы 1 `c3aa4cbbb53de134:d4fb75e2:0` → `c3aa4cbbb53de134:1b30b1d ## 6. ЧТО НЕ ПРОВЕРЕНО И ПОЧЕМУ -- **Полный каталог (205 записей) не гонялся.** Прогнаны 6 записей, целящих в изменённое поведение. Причина — +- **Полный каталог (211 записей) не гонялся.** Прогнаны 14 записей, целящих в изменённое поведение. Причина — цена: 124 записи гоняют `./internal/pipeline/`, чей набор идёт ~145 с, то есть полный прогон — часы при - загруженном хосте. Механически проверено другое и дешёвое: **у всех 205 записей цель ЖИВА** (греп `find` + загруженном хосте. Механически проверено другое и дешёвое: **у всех 211 записей цель ЖИВА** (греп `find` по дереву), то есть протухших целей после пака не осталось. -- **EPUB владельца (Кристофф/en, isekai/ja) не прогонялись через новый путь.** §3 выводит их из гейта, а - §2.1 п.3 требует ОРАКУЛА, а не замера; оракул построен кодом (`TestEPUBNavOracleReproducesTheTxtCut`). - Замер их нового разреза числом — не сделан. +- ⚠ **Эта строка УСТАРЕЛА и оставлена как след:** на момент первой редакции EPUB владельца через новый путь + не прогонялись. Они прогнаны позже — §8 и §10 несут их числа. Строку не удаляю, потому что она показывает, + в каком порядке появлялось знание. - **Платная книга платформы** не трогалась — чужая зона. - **Сквозного прогона не-CJK нет** — промтов пары для en/ja не существует (§5), проверка ограничена уровнем ingest/manifest, $0. @@ -434,3 +439,88 @@ go test ./... -count=1 -v → EXIT 0, o ⚠ **Это второй раз за смену, когда правку ловил пере-замер, а не тест.** Первый — Д-2 (не пере-мерил книгу владельца после починки). Норма работает ровно потому, что она про ЭКЗЕМПЛЯР, а не про мнение. +--- + +## 12. ВЫЧИТКА ПОСЛЕ ЛЕНДИНГА — ЧТО НАШЛОСЬ В СВОЁМ ЖЕ КОДЕ + +Пак был уже заланден (`2f65d1d`), когда владелец спросил, всё ли проверено. Простое чтение собственного +дифа глазами — то, чего я за весь пак ни разу не сделал подряд, — дало находки за минуты и без единого агента. + +### Дефекты кода (починены) + +| # | что | почему это дефект | +|---|---|---| +| В-1 | **метка `nav` не триммилась**, а её сёстры триммят | один заголовок давал `"\n Chapter One\n "` через nav и `"Chapter One"` через NCX — два ответа на один вопрос, решаемые тем, какую таблицу собрал издатель. Едет на провод в `title_raw`. Фикстуры пака не ловили: они пишут метку без отступов | +| В-2 | **поиск заголовка в манифесте без нижней границы** | сосед шестью строками выше пишет `k >= 0 && k < len(ids)` и деградирует с WARN, «если плотная нумерация однажды перестанет держаться». Моя строка в том же случае даёт панику индекса на платном прогоне | +| В-3 | `ncxPath` — единственный резолвер href, не раскодирующий проценты | ровно та асимметрия, которую пак свёл в `hrefTarget`; я свёл два резолвера и не заметил третий | + +Пин на В-1 — `TestNavLabelIsTrimmedLikeItsSiblings`, предъявлен посадкой (снять trim → красный). + +### Протухшие носители (девять, починены) + +`resolveTOC` обещал счёт нелинейных целей, которого я сам же лишил его при M-5 · `epubTOC.unresolved` ссылался +на `DocumentsExcluded`, переименованный в `Excluded` · `isChapterHeader` нёс имя `isCJKChapterHeader` · +`Chunk.Heading` — «detected via the pair's lang.HeadingRule» · `stripHeading` в `SplitChunksWithChapters` — +«A nil rule» · шапка `ingest.go` и `chapterSep` — «form feed семантически граница главы» и «epub несёт +настоящую структуру глав в spine», то есть ровно те два убеждения, которые пак опровергает · `detectChapterUnit` +— четыре юнита и не тот источник · `buildManifest` — параметр `chapterTexts`, которого нет · `bookfile/model.go` +— «титульный документ был бы прочитан как отдельная глава» · комментарий сторожа в `ingestnotes_test.go` +обещал «любой тип», а `switch` покрывает два. + +⛔ **Класс один и тот же: утверждение, верное на момент написания и не пере-прочитанное после правки.** Он же +дал шесть находок приёмки и четыре моих. Дешевле всего он ловится не тестом, а чтением подряд. + +### Ошибки в ЭТОМ отчёте (исправлены) + +Каталог 205 → **211** (число было от первого круга) · таблица §1 писала `make battery` в обеих строках и +противоречила §11 того же отчёта · «Счета формата» перечисляли пять, их шесть · строка §6 «EPUB владельца не +прогонялись» осталась после того, как §8 и §10 их прогнали. + +### Перепись гейта была неверной — и я поймал её собственной нормой дня + +Я считал конфиги **по ИМЕНИ файла** (`book.yaml`, `*.book.yaml`) и получил 31 конфиг / 8 исходников. +Счёт **по СОДЕРЖИМОМУ** (файл несёт `book_id` и `source_file`) даёт **39 конфигов и 12 различных +исходников**. Четыре источника семьи 蛊真人 под `books/gu-zhenren/exp15/` гейт не покрывал: их конфиги +называются `exp15-*.yaml`, и фильтр по имени их не видел. + +**Это четвёртый за смену случай класса «имя против содержимого» — и первый, где на него наступил я сам, +уже объявив его нормой.** + +Четыре пропущенных источника пинованы «до» и «после» отдельно: + +| исходник | глав | structure до → после | разрез | +|---|---|---|---| +| `S2prime.txt` | 1 | none → none | байт-в-байт | +| `S2prime_big_flat.txt` | 1 | none → none | байт-в-байт | +| `S2prime_big_blanklines.txt` | 1 | none → none | байт-в-байт | +| `smoke.txt` | 1 | none → none | байт-в-байт | + +Пробел последствий не имел, но он БЫЛ, и закрыт замером, а не доводом. + +### Два заказа §2.1/§2.5, которых я не сделал и не назвал + +⛔ **Полная 蛊真人 (2283 главы) через новый путь не прогонялась.** §2.1 велит: «Полную книгу гоняешь +ОТДЕЛЬНО, СВОИМ конфигом в своём каталоге; её результат идёт числом в отчёт». Я записал в §6 «у неё нет +конфига» — но отсутствие конфига и есть то, что заказ приказывал устранить. **Это не «не проверено», это +не сделано.** + +⛔ **Машинная карта сдвигов не была построена и не была названа среди непроверенного.** §2.5: «Прозой +нельзя: на прозе не построить мигратор потом». Отчёт давал прозаическую таблицу восьми строк. + +### ОБА ЗАКАЗА ИСПОЛНЕНЫ ПОСЛЕ ВЫЧИТКИ + +**Полная 蛊真人 прогнана своим конфигом** (`~/tm-pins-0509/full/`, `tmctl manifest`, EXIT 0, 10 с, $0): +**2283 главы**, `structure = detected`, 4276 юнитов, 5071 чанк, `title_raw` заполнен у **2283 из 2283**. +Счёт глав сошёлся с независимым замером шапок шага 0 (2283 строки `第N节`) — два прибора, один ответ. + +**Машинная карта сдвигов построена** — `backend/docs/CHAPTER_STRUCTURE_SHIFTMAP.json`, генератор рядом +(`chapter_structure_shiftmap.py`). Set-diff по id и номеру, `same/moved/gone/new`, по **тринадцати** +источникам: восемь пинов гейта, четыре пропущенных переписью, и полная книга. + +| | глав | same | moved | gone | new | +|---|---|---|---|---|---| +| **все 13 источников** | **2355** | **2355** | **0** | **0** | **0** | + +⇒ ветка (а) гейта §3 предъявлена не таблицей глазами, а машинным набором, и на нём строится мигратор, +если он понадобится. Это и было содержанием заказа. + diff --git a/backend/docs/CHAPTER_STRUCTURE_SHIFTMAP.json b/backend/docs/CHAPTER_STRUCTURE_SHIFTMAP.json new file mode 100644 index 00000000..da2e83d5 --- /dev/null +++ b/backend/docs/CHAPTER_STRUCTURE_SHIFTMAP.json @@ -0,0 +1,275 @@ +[ + { + "source": "ch1", + "before": { + "chapters": 1, + "structure": "none", + "key": "1199f7ef6aff4863dbd6bcdacf7f9313ea928d79f5f216ab5687d5f2c9022bf4" + }, + "after": { + "chapters": 1, + "structure": "none", + "key": "37db0ea49c3dcae65c43a8ab22e5c054c8f252c6cac509cf66e69e63169bcf41" + }, + "same": 1, + "moved": 0, + "gone": 0, + "new": 0, + "moved_detail": [], + "gone_ids": [], + "new_ids": [], + "cut_unchanged": true + }, + { + "source": "ch1-2", + "before": { + "chapters": 2, + "structure": "detected", + "key": "f368bcf0e25bdff52e6f14a751ccc13e151b2bc475d4931cec30c0cd7f255be2" + }, + "after": { + "chapters": 2, + "structure": "detected", + "key": "cdb5056e9a7440b2ba1fa6bcead16b4370d72a6ce0dc231e76e02b441c1e19f1" + }, + "same": 2, + "moved": 0, + "gone": 0, + "new": 0, + "moved_detail": [], + "gone_ids": [], + "new_ids": [], + "cut_unchanged": true + }, + { + "source": "ch1-5", + "before": { + "chapters": 5, + "structure": "detected", + "key": "1f2be0a61722c80cbf15e7539e5cd4826ee409aefb95ff9795d2a16137c7f2de" + }, + "after": { + "chapters": 5, + "structure": "detected", + "key": "748f069774e72cbdeb3e3e344a05e392e60205161db819c6149d52d948445fa4" + }, + "same": 5, + "moved": 0, + "gone": 0, + "new": 0, + "moved_detail": [], + "gone_ids": [], + "new_ids": [], + "cut_unchanged": true + }, + { + "source": "ch4-5", + "before": { + "chapters": 2, + "structure": "detected", + "key": "be3010c163605ac444d5486e49599e4f0af19760858d9787c333d23ecfb9e360" + }, + "after": { + "chapters": 2, + "structure": "detected", + "key": "b15aa9fa7837e373ff0893774978f9e423d863aa7119425ca8fbb1116a1568f0" + }, + "same": 2, + "moved": 0, + "gone": 0, + "new": 0, + "moved_detail": [], + "gone_ids": [], + "new_ids": [], + "cut_unchanged": true + }, + { + "source": "ch1-10", + "before": { + "chapters": 10, + "structure": "detected", + "key": "1e62a68d93f73534c27a1c67b1e98b813d5a5916986159259f307bd635e9bb03" + }, + "after": { + "chapters": 10, + "structure": "detected", + "key": "01a00e3669302bc14accd9d05a8f6a0155398f817ac1d1419d6dbc92aa9419a1" + }, + "same": 10, + "moved": 0, + "gone": 0, + "new": 0, + "moved_detail": [], + "gone_ids": [], + "new_ids": [], + "cut_unchanged": true + }, + { + "source": "s11-16", + "before": { + "chapters": 6, + "structure": "detected", + "key": "b806ba144cecc402e4d130a7b42d4f04c9d63ca4d114e9772ef95ab7d8d4b577" + }, + "after": { + "chapters": 6, + "structure": "detected", + "key": "c1148887ca4c977339969a6fc99f740d7b5a8674f8a602b34572dc1dbc71d4a8" + }, + "same": 6, + "moved": 0, + "gone": 0, + "new": 0, + "moved_detail": [], + "gone_ids": [], + "new_ids": [], + "cut_unchanged": true + }, + { + "source": "slice25", + "before": { + "chapters": 25, + "structure": "detected", + "key": "084b129293ba410f7245a57ee99b84a2bb4f3c4bb32aa6a24ff0a52c4e3fc246" + }, + "after": { + "chapters": 25, + "structure": "detected", + "key": "6dbbbe3490343526781f9577c852426802b2b1b1962526e71bc07c28d8f4643b" + }, + "same": 25, + "moved": 0, + "gone": 0, + "new": 0, + "moved_detail": [], + "gone_ids": [], + "new_ids": [], + "cut_unchanged": true + }, + { + "source": "ch26-42", + "before": { + "chapters": 17, + "structure": "detected", + "key": "8f7aab0de5e785f0e25b89ebb2730c55c5fdc435cbcfb234a91c54608a42107d" + }, + "after": { + "chapters": 17, + "structure": "detected", + "key": "8a936b25aa719179557a3c4728e1f1cfa5d4ffa0888a03d5407de9fdb74ef837" + }, + "same": 17, + "moved": 0, + "gone": 0, + "new": 0, + "moved_detail": [], + "gone_ids": [], + "new_ids": [], + "cut_unchanged": true + }, + { + "source": "S2prime", + "before": { + "chapters": 1, + "structure": "none", + "key": "511c1f6e9abe2c0a1818c0bfc5ba14d970ae18869195b4f3d0acea4fd32fe7a9" + }, + "after": { + "chapters": 1, + "structure": "none", + "key": "b3ce41a67e170d3cf1c912a197bf279a1ae22515fc9665b335d6a2c97596ee24" + }, + "same": 1, + "moved": 0, + "gone": 0, + "new": 0, + "moved_detail": [], + "gone_ids": [], + "new_ids": [], + "cut_unchanged": true + }, + { + "source": "S2prime-flat", + "before": { + "chapters": 1, + "structure": "none", + "key": "0e7c8df7be6edbf0c0c7cd4d511d9ff0bb6a8f996a051cd214dc2378bbfc34e1" + }, + "after": { + "chapters": 1, + "structure": "none", + "key": "084466f455aff0cbd32533d7f4d192e1a7e1243706dc3a0469190b0c4516d703" + }, + "same": 1, + "moved": 0, + "gone": 0, + "new": 0, + "moved_detail": [], + "gone_ids": [], + "new_ids": [], + "cut_unchanged": true + }, + { + "source": "S2prime-blank", + "before": { + "chapters": 1, + "structure": "none", + "key": "c2f95f265b7772fe2834230e8e581119f03a00c8b02a460afc54e05b970f8b04" + }, + "after": { + "chapters": 1, + "structure": "none", + "key": "6c972cea05026d68a65d2b527f04bbbea8afcfe1d28bea933fe5a4b5b9f96626" + }, + "same": 1, + "moved": 0, + "gone": 0, + "new": 0, + "moved_detail": [], + "gone_ids": [], + "new_ids": [], + "cut_unchanged": true + }, + { + "source": "smoke", + "before": { + "chapters": 1, + "structure": "none", + "key": "a071430a17b96f7995bd54855c76fcf55d97ab082915351ec7ede748342d798b" + }, + "after": { + "chapters": 1, + "structure": "none", + "key": "69131330d13d6d5f024eaca800c748466ed6a02e5240c0aeee081f274d601af2" + }, + "same": 1, + "moved": 0, + "gone": 0, + "new": 0, + "moved_detail": [], + "gone_ids": [], + "new_ids": [], + "cut_unchanged": true + }, + { + "source": "guzhenren-full-2283", + "before": { + "chapters": 2283, + "structure": "detected", + "key": "28fa8b38eda95e94f68c640901152350c66041dea7fd898e1710f02857f0c4c5" + }, + "after": { + "chapters": 2283, + "structure": "detected", + "key": "d1a249ec0b8a3dc7ab59d070409659bc520b77b496e1cb73b2cd5e295dd7a68c" + }, + "same": 2283, + "moved": 0, + "gone": 0, + "new": 0, + "moved_detail": [], + "gone_ids": [], + "new_ids": [], + "cut_unchanged": true + } +] diff --git a/backend/docs/chapter_structure_shiftmap.py b/backend/docs/chapter_structure_shiftmap.py new file mode 100644 index 00000000..9d33e2b5 --- /dev/null +++ b/backend/docs/chapter_structure_shiftmap.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Машинная карта сдвигов, заказанная §2.5: set-diff по id и номеру, same/moved/gone/new.""" +import json, sys + +def load(p): + m = json.load(open(p, encoding="utf-8")) + return {c["id"]: c["number"] for c in m["chapters"]}, m + +def diff(before_path, after_path, source): + a, ma = load(before_path) + b, mb = load(after_path) + same = sorted(i for i in a if i in b and a[i] == b[i]) + moved = sorted(i for i in a if i in b and a[i] != b[i]) + gone = sorted(i for i in a if i not in b) + new = sorted(i for i in b if i not in a) + return { + "source": source, + "before": {"chapters": len(a), "structure": ma.get("structure"), "key": ma.get("key")}, + "after": {"chapters": len(b), "structure": mb.get("structure"), "key": mb.get("key")}, + "same": len(same), "moved": len(moved), "gone": len(gone), "new": len(new), + "moved_detail": [{"id": i, "from": a[i], "to": b[i]} for i in moved][:200], + "gone_ids": gone[:200], "new_ids": new[:200], + "cut_unchanged": not moved and not gone and not new, + } + +if __name__ == "__main__": + out = [diff(*t) for t in (sys.argv[1:][i:i+3] for i in range(0, len(sys.argv)-1, 3))] + json.dump(out, sys.stdout, ensure_ascii=False, indent=1) + print() diff --git a/backend/internal/chunk/chunker.go b/backend/internal/chunk/chunker.go index 196546a7..c3b08940 100644 --- a/backend/internal/chunk/chunker.go +++ b/backend/internal/chunk/chunker.go @@ -60,8 +60,8 @@ type Chunk struct { // ("degradation is not silent") rather than silently oversized. OversizedSentence bool // Heading is the deterministic chapter title, non-empty ONLY on a chapter's FIRST chunk (ChunkIdx 0) - // when the chapter opened with a source structural header (detected via the pair's lang.HeadingRule) - // AND a rule is configured. The chunker STRIPS the source marker from ch.Text (so the model never + // when the chapter opened with a source structural header (RECOGNISED by the source language's + // lang.SourceStructure) AND the pair supplies a template. The chunker STRIPS the source marker from ch.Text (so the model never // renders its own «Раздел 2»/«Первая глава»/an orphaned « :») and renders this from the rule's template // («Глава N»); the read-models PREPEND it to the chapter's first output unit. "" for every non-first // chunk, every chapter without a header, and every book without a heading rule (in which case the source @@ -128,7 +128,7 @@ func SplitChunksWithChapters(chapters []string, seg SegBudget, rule *lang.Chapte editUnitID := 0 for idx, ingested := range chapters { // Title policy (pack-13): detect a leading structural header, render it deterministically and - // STRIP its marker from the text the model sees. A nil rule (no pack / no heading.txt) or a chapter + // STRIP its marker from the text the model sees. An absent TEMPLATE (no pack / no heading.txt) or a chapter // with no header is a NO-OP — headingText is "" and chapText is unchanged, so a book that does not // opt in produces byte-identical chunks. Runs BEFORE splitParagraphs so the stripped subtitle is // re-paragraphed normally. @@ -202,7 +202,7 @@ func ApplyHeading(heading, finalText string) string { // matchHeaderLine reports whether a single line is a structural header under the rule and returns the // parsed section number + the trimmed subtitle after the marker. Shape: , -// then EITHER end-of-line OR a SEPARATOR (not a content glyph) — mirroring ingest.isCJKChapterHeader's +// then EITHER end-of-line OR a SEPARATOR (not a content glyph) — mirroring ingest.isChapterHeader's // precision guard so «第一回见面» (回 a measure word glued to the content 见) is NOT a header, while // «第一节:…» / «第1章 …» is. Leading whitespace is tolerated; the subtitle is the remainder with any // leading separators (:: 、,.。- —  ·) trimmed. Deterministic. diff --git a/backend/internal/chunk/epubtoc.go b/backend/internal/chunk/epubtoc.go index 7ccd0d9a..21f4b753 100644 --- a/backend/internal/chunk/epubtoc.go +++ b/backend/internal/chunk/epubtoc.go @@ -56,7 +56,7 @@ type epubTOC struct { // ⚠ It deliberately does NOT count a target that resolved fine but landed on a service page or a // non-linear one. Those are ordinary and expected — an EPUB 2 whose toc lists its own cover would report // a "broken" table of contents on every normal book, and an alarm that fires on the normal case is one - // nobody reads. Their exclusion is already counted, once, as DocumentsExcluded. + // nobody reads. Their exclusion, when they are service pages, is counted once in Document.Excluded. unresolved int // collapsed counts targets that landed in a document another target had already claimed. // @@ -172,6 +172,13 @@ func parseNavDoc(data []byte, baseDir string) (toc []string, labels map[string]s case html.EndTagToken: switch name, _ := z.TagName(); string(name) { case "a": + // Trim on CLOSE, where the whole label is in hand. Real books indent their nav, so an + // untrimmed label ships "\n Chapter One\n " as the chapter's source title — while + // the NCX half (parseNCX) and the txt half (chapterTitleRaw) both trim. One title, two + // answers, decided by which table the book happens to carry. + if pending != "" { + labels[pending] = strings.TrimSpace(labels[pending]) + } pending = "" case "nav": navType, depth, pending = "", 0, "" @@ -325,16 +332,20 @@ func isServiceRole(role string) bool { // when that attribute is missing, as it is in books built by tools that half-migrated to EPUB 3 — the first // item carrying the NCX media type. func ncxPath(opf opfPackage, opfDir string) string { + // Resolved through the SAME function every other href in this package uses. It was the one resolver + // that percent-decoded nothing, which is how two halves of one package came to disagree the first time. if id := strings.TrimSpace(opf.Spine.TOC); id != "" { for _, it := range opf.Manifest { if it.ID == id { - return path.Clean(path.Join(opfDir, it.Href)) + doc, _ := hrefTarget(opfDir, it.Href) + return doc } } } for _, it := range opf.Manifest { if strings.Contains(strings.ToLower(it.MediaType), "dtbncx") { - return path.Clean(path.Join(opfDir, it.Href)) + doc, _ := hrefTarget(opfDir, it.Href) + return doc } } return "" diff --git a/backend/internal/chunk/epubtoc_test.go b/backend/internal/chunk/epubtoc_test.go index 04d1737f..b8dfccdb 100644 --- a/backend/internal/chunk/epubtoc_test.go +++ b/backend/internal/chunk/epubtoc_test.go @@ -672,3 +672,36 @@ func TestEPUB3LandmarkWithAFragmentDoesNotDeleteTheDocument(t *testing.T) { doc.ServiceRolesInsideDocuments) } } + +// TestNavLabelIsTrimmedLikeItsSiblings pins the answer to be the same whichever table the book carries. +// +// ⛔ Real books indent their nav. Untrimmed, one title arrives as "\n Chapter One\n " through a +// nav and as "Chapter One" through an NCX — the same chapter, two answers, decided by which table the +// publisher happened to ship. It goes on the wire as title_raw, so a reader renders the whitespace. +// The pack's own fixtures could not catch it: they write labels inline, with no indentation to trim. +func TestNavLabelIsTrimmedLikeItsSiblings(t *testing.T) { + indented := []byte(` + +`) + _, navLabels, _, _ := parseNavDoc(indented, "OEBPS") + ncx := []byte(` + Chapter One + `) + _, ncxLabels := parseNCX(ncx, "OEBPS") + + const key, want = "OEBPS/c1.xhtml", "Chapter One" + if navLabels[key] != want { + t.Fatalf("nav label = %q, want %q", navLabels[key], want) + } + if navLabels[key] != ncxLabels[key] { + t.Fatalf("one title, two answers: nav %q vs ncx %q", navLabels[key], ncxLabels[key]) + } +} diff --git a/backend/internal/chunk/ingest.go b/backend/internal/chunk/ingest.go index 06b556e4..97fd935e 100644 --- a/backend/internal/chunk/ingest.go +++ b/backend/internal/chunk/ingest.go @@ -30,18 +30,18 @@ import ( // doc.Ruby (seeding.go). It is offline and deterministic — no LLM, no time/rand — so // the whole path is $0 and reproducible. // -// epub v1 is a text extraction (02-mvp:25): read the chapters in spine order, -// strip tags, drop inline markup/styling. The ONE thing we do NOT drop is ruby: +// epub is a text extraction (02-mvp:25): read the chapters the book's own table of contents declares — +// nav (EPUB 3) or NCX (EPUB 2), falling back to the spine only when neither resolves — strip tags, drop +// inline markup/styling. The ONE thing we do NOT drop is ruby: // basereading carries an author reading of a name/term that a // plain text export would silently lose (the documented hole, 04-unhappy §4). We // keep the BASE in the body and hand the READING to the caller for a glossary lock // (memory v2, step 4) — never injecting it into a prompt here (§7d). -// chapterSep splits a TXT source into chapters. Form feed (U+000C) is the ASCII -// "page/section break": semantically a chapter boundary, invisible in prose, and -// untouched by text.NormalizeSource. A txt with no \f is a single chapter (Phase-0 backward -// compat: the one-file example stays one chapter). epub carries real chapter -// structure in its spine, so it does not use this. +// chapterSep is the ASCII form feed (U+000C), the "page/section break". It is a SEPARATOR the file +// carries, NOT a statement that a chapter begins — see the Structure constants. It is invisible in prose +// and untouched by text.NormalizeSource, and it draws the boundaries only when no header line does +// (cutTXT). A txt with neither is a single chapter. epub has its own table of contents and never uses this. const chapterSep = "\f" // RubyReading is one captured (base, reading) pair and the chapter it appeared in. @@ -291,7 +291,8 @@ func cutTXT(decoded string, st *lang.SourceStructure) (chapters []string, witnes // legitimate subtitle → merged chapters). const chapterHeaderMaxRunes = 60 -// isCJKChapterHeader reports whether a single line is a chapter header for the given unit rune. It +// isChapterHeader reports whether a single line is a chapter header for the given unit rune, under the +// book's resolved source structure. It // requires: a short trimmed line (≤60 runes); a leading 第; the unit rune immediately // after the numerals; and — critically — the char AFTER the unit is a SEPARATOR (whitespace / ::、,. // / dash) or end-of-line, NOT a content glyph. The separator guard is the precision fix (self- @@ -341,7 +342,8 @@ func detectChapterUnit(lines []string, st *lang.SourceStructure) rune { return 0 } best, bestN := rune(0), 0 - // The section markers (章节節回) are shared lang.CJKSection data (pair-14 addendum-A); iterated in AUTHORED + // The section units come from the book's RESOLVED source structure (lang.SourceStructure — its own + // /structure.txt, else the embedded CJK default); iterated in AUTHORED // order so a tie between two units breaks deterministically (first-seen), as the fixed slice once did. for _, unit := range st.UnitOrdered { n := 0 diff --git a/backend/internal/pipeline/manifest.go b/backend/internal/pipeline/manifest.go index da762652..a0769647 100644 --- a/backend/internal/pipeline/manifest.go +++ b/backend/internal/pipeline/manifest.go @@ -400,7 +400,8 @@ func denseFrom(doc *chunk.Document, keptIdx []int) denseChapters { return out } -// buildManifest projects a fresh split into the document. chapterTexts is index-aligned to chapter +// buildManifest projects a fresh split into the document. ch holds the chapters that took a number and +// their source titles, index-aligned to chapter // numbers (chunk.SplitChunksWithChapters), chunks is the split itself. Pure and deterministic. func (r *Runner) buildManifest(ch denseChapters, chunks []chunk.Chunk, structure string, src sourceFingerprint) *BookManifest { m := &BookManifest{ @@ -452,8 +453,12 @@ func (r *Runner) buildManifest(ch denseChapters, chunks []chunk.Chunk, structure "book", r.Book.BookID, "chapter", u.Chapter, "chapter_texts", len(ids)) } title := "" - if u.Chapter-1 < len(ch.titles) { - title = ch.titles[u.Chapter-1] // Number is dense and 1-based, so it indexes the dense slice + // The SAME guard the chapter-id lookup six lines above carries, and for the same reason: that + // branch exists because dense 1-based numbering might one day stop holding, and it degrades with + // a WARN. Without the lower bound this line turns that warned degradation into an index-out-of- + // range panic on a paid run. + if k := u.Chapter - 1; k >= 0 && k < len(ch.titles) { + title = ch.titles[k] } m.Chapters = append(m.Chapters, ManifestChapter{ID: id, Number: u.Chapter, Heading: headings[u.Chapter], TitleRaw: title}) i = len(m.Chapters) - 1