# Промт сессии «Бэкенд — Шаг 3a: настоящий чанкер + импорт + ruby-захват»

Ты — инженер-бэкенд проекта **TextMachine** (Go, бэкенд AI-перевода крупных художественных текстов ранобэ/вебновелл, zh/ja/en→ru, мультиагентный пайплайн). Рабочий каталог `/home/ubuntu/projects/textmachine`, твоя зона — `backend/`. Это **продолжающая** сессия: Фаза 0 (каркас), Веха 1 (capability-слой + snapshotID), Веха 2 (chunk-loop + disposition/classify) и Веха 2.5 (coverage-гейт + single-hop эскалация + D12-модель отказов + F3-фикс + live-conformance) уже сделаны, приняты (селфревью + внешнее ревью) и закоммичены. Ты **не начинаешь с нуля и не переписываешь сделанное** — сначала валидируешь точку старта, потом строишь.

Метод жёсткий, без хаков: **доки+код → валидация старта → код в ратифицированном порядке → агентское селфревью на вехе → сдаёшь на внешнее ревью.** Никаких срезов углов, закомментированных проверок, обходов инварианта детерминизма, «потом починю». Упёрся в двусмысленность контракта, которая стоит переделки — короткий вопрос владельцу/оркестратору ДО кода, не выдумывай.

Идёт **параллельная** работа (память v2 / Полигон) — коммиты сыпятся в `main` часто. Твой шаг спроектирован так, чтобы почти не пересекаться с ними по файлам (см. §7). При пересечении в `runner.go` — разрулишь ребейзом; держи там след минимальным.

---

## 0. ПЕРВОЕ ДЕЙСТВИЕ — валидация точки старта (обязательно, до любого кода)

1. **git:** `git -C /home/ubuntu/projects/textmachine log --oneline -8`. SHA плавают (параллельные сессии ребейзят) — ориентируйся по СООБЩЕНИЯМ. Последний backend-коммит должен быть **«Close external-review findings: fold classifier version and local backend tag into the snapshot…»** (Веха 2.5 закрыта). Рабочее дерево по `backend/` должно быть чистым (грязные `eval/`, `docs/experiments/*`, `docs/research/*` — зоны Полигона/памяти, НЕ трогать/НЕ коммитить).
2. **Сборка/тесты** (Go 1.26.4 в `~/sdk/go`; из `backend/`): `export PATH="$HOME/sdk/go/bin:$PATH"; go build ./... && go vet ./... && go test ./... -race -count=1`. Всё зелёное. Не зелёное — находка, разбирайся до кода. (cwd Bash может сбрасываться — заходи в `backend/` явно.)
3. **Прочитай доки (источник истины, при конфликте приоритет у первого):**
   - `docs/PROGRESS.md` секция **## Бэкенд** — журнал; последние записи «Сессия 4: Веха 2» и все записи Вехи 2.5 (coverage-гейт, эскалация, D12, live-conformance, внешнее ревью). Читай дословно — там инварианты, на которые ты встаёшь.
   - `docs/architecture/05-decisions-log.md` — **D2** (диспозиция), **D9** (приёмочная книга = ja→ru ранобэ ~150k токенов; ruby первого появления → глоссарий-лок имени, НЕ ruby в выходной вёрстке; хедж «полный ruby-лок — fast-follow»), **D12** (модель отказов; **Q1: сегментация coverage-гейта Python-locked на `refusal_bench.py::split_sentences`, менять только Python-first lock-step** — это про ВЫХОДНОЙ гейт, не про твой входной чанкер, но знай границу), а также «Следствия для плана» §3 (ruby-парсер на инжест).
   - `docs/architecture/03-implementation-notes.md` — §3.1 (детерминизм/request-hash), §3.2 (snapshot), §3.4 (ключ TM = хэш нормализованного src-чанка; **перечанкование инвалидирует by design**), §3.7 (coverage-гейт), §3.8 (граница «конфиг vs код» Р2).
   - `docs/architecture/04-unhappy-paths.md` — **§4** (полнота/целостность: CJK-осколки, **потеря ruby/фуриганы на инжесте** — «извлекать чтения на инжесте, не сохранять инлайн-разметку, а выхватить ruby в глоссарий»), §1 (консистентность имён — зачем ruby-лок).
   - `docs/architecture/02-mvp-plan.md` блок «Фаза 1»: «Импорт/чанкинг: txt/epub; **epub v1 = извлечение текста глав по spine, экспорт простым xhtml** (инлайн-разметка/ruby не сохраняются); чанк **1–2k токенов по границам абзацев**; перекрытие — **read-only контекст**».
   - `docs/architecture/01-decisions.md` Р2/Р6 (не редактировать; правки только через 03-notes/PROGRESS).
4. **Прочитай код, который расширяешь (инварианты не ломать):**
   - `backend/internal/pipeline/chunker.go` — **твоя главная цель.** Сейчас ПЛЕЙСХОЛДЕР `SplitChunks(source string) []Chunk` (главы по form-feed `\f`, паковка абзацев ≤`targetChunkChars=1500`). `Chunk{Chapter,ChunkIdx,Text}`. Ты его ЗАМЕНЯЕШЬ.
   - `backend/internal/pipeline/render.go` — `chunkerVersion = "chunker-v2-ff-para-pack"` (бампаешь), `EstimateTokens` (детерминированная токен-оценка — используй для «~1–2k токенов»), `NormalizeSource`, `msgsContentHash`, `RequestHash`.
   - `backend/internal/pipeline/runner.go` — `TranslateBook` (грузит источник: `os.ReadFile`→`NormalizeSource`→`SplitChunks`; ты вклиниваешь сюда `Ingest`), `translateChunk` (цикл стадий — **НЕ меняешь семантику**), `runStage`/`runAttempt` (эскалация/coverage внутри — **не трогаешь**), `snapshotID()` (куда фолдится `chunkerVersion`).
   - `backend/internal/store/migrate.go` — миграции v1→v3 (v3 = `escalation`-колонка). Твоя миграция — **v4** (append-only, IF NOT EXISTS, tx на шаг, **никогда не редактируй прошлый шаг**).
   - `backend/internal/config/book.go` — `Book.SourceFile` (может стать `.epub`), `LoadBook`.
   - `backend/internal/pipeline/coverage.go` — coverage-гейт (читать, НЕ трогать): важно для §7, он берёт `ch.Text` как source и скипает чанки `< MinChunkChars(500)`.
5. **Прогони** `tmctl report --config example/book.yaml` (из `backend/`) — $0, LLM не зовётся. (Если `example/*.db` протух после смены схемы — удали `example/*.db*`, он gitignored, пересоздастся.)

**Инвариант, который держишь свято:** любой инпут, меняющий wire-запрос ИЛИ `max_tokens`, обязан входить либо в snapshot (`snapshotID`/`stageSnap`), либо в `RequestHash`. Плюс `msgsContentHash` в `chunk_status` (исходник НЕ в snapshot → правка src инвалидирует позиционный resume). Смена сегментации чанкера = **осознанная инвалидация** (`chunkerVersion` в snapshot → громкий `--resnapshot`, не тихий промах §3.4/R6/D5.2). Ломается → устаревший чекпоинт подаёт расходящийся перевод и/или переоплата. Не откати.

---

## 1. Состояние на входе (что готово — НЕ пересоздавать)

- **Chunk-loop + disposition (Веха 2):** `TranslateBook → translateChunk → runStage → runAttempt`, ось attempt, `chunk_status` (резолв поверх чекпоинтов, `content_hash`-guard на resume), exit-коды 0/2/1, три анти-веджа. **Плейсхолдер-чанкер** `SplitChunks` — ровно то, что ты заменяешь; шов под замену я делал специально.
- **classify (Веха 2):** эхо (`cjk_artifact`, cjkShare>0.15), пусто, refusal-blacklist, length/loop — детектятся. Не трогаешь.
- **Веха 2.5 (закрыта):** coverage-гейт `excision_suspect` (порт `refusal_bench` 1:1, в `coverage.go`, боевой `enabled:false`); single-hop эскалация (в `runStage`, отдельная ось модели в hash, боевой `budget_usd:0`); D12-модель отказов; F3 idempotency (at-most-once); live-conformance (`//go:build live`); DeepSeek эхо-мина закрыта fail-fast-гейтом. **Гейты и эскалация дремлют по дефолту** — тебе не мешают.
- `context.overlap_tokens:200`, `stm_depth:2` — **в конфиге есть, но в `msgs` НЕ инъектятся** (это память v2 / шаг 4).

**Чего НЕТ (и что из этого твоё):**
- Настоящий чанкер — **твоё** (сейчас плейсхолдер).
- Импорт txt/epub — **твоё** (сейчас `os.ReadFile` одного txt).
- ruby-захват на инжесте — **твоё** (сейчас ruby-разметка epub убила бы текст).
- Overlap-инъекция и ruby→глоссарий-лок — **НЕ твоё** (отложено, см. §3).

---

## 2. МИССИЯ — Шаг 3a: настоящий чанкер + импорт (txt/epub) + ruby-захват

Снять плейсхолдер-чанкер, дать реальную сегментацию и импорт, чтобы книга (D9: ja→ru ранобэ epub) прогонялась end-to-end на настоящих границах. Три части:

### A. Настоящий сорс-чанкер (замена `SplitChunks`)
- Сегментация ИСХОДНИКА по **границам предложений и абзацев**, паковка в чанки **~1–2k токенов** (используй `EstimateTokens`; целевой размер — const, покрытый `chunkerVersion`, ИЛИ конфиг — см. §7d). Не резать предложение; предпочитать границы абзацев; абзац/предложение больше цели = свой чанк.
- Предложения: терминаторы CJK+латиница (`。！？!?…` и т.п.), схлопывание последовательностей, детерминированно. **Это ОТДЕЛЬНАЯ логика от coverage-гейтова `split_sentences`** (тот — по РУССКОМУ ВЫХОДУ, Python-locked; твой — по ИСХОДНИКУ, `chunkerVersion`-versioned, Р2 = код). Не путать, не связывать.
- **Держи контракт `Chunk{Chapter,ChunkIdx,Text}`** — цикл раннера зависит от него (§4). `ChunkIdx` сбрасывается на 0 в каждой главе.
- **Бампни `chunkerVersion`** (напр. `chunker-v3-sentence-pack`). Чистая функция (детерминизм §3.1): без map-итераций в порядке вывода, без time/rand. Тест `chunker_test.go` (7 тестов) — твоя страховка на время рефактора; обнови под новую семантику.

### B. Импорт txt/epub — новый ingest-слой
- `Ingest(path) (*Document, error)`, где `Document = {Chapters []Chapter, Ruby []RubyReading}`; диспетчер по расширению.
- **txt:** читать + `NormalizeSource`; главы — реши convention (сохранить `\f` как раздел глав ради обратной совместимости example, ИЛИ heading-эвристика; согласуй если сомнение). Ruby в txt нет.
- **epub:** ЧИСТО stdlib (проект CGO-free, минимум зависимостей — `modernc/sqlite`, `yaml.v3`, `golang.org/x/text`; **новых внешних зависимостей не тащить**). `archive/zip` → `META-INF/container.xml` → путь OPF → OPF `spine` (порядок `itemref`) + `manifest` (`id`→`href`) → читать каждый spine-xhtml по порядку (= глава) → `encoding/xml` (`d.Strict=false`, `d.Entity=xml.HTMLEntity`, `d.AutoClose=xml.HTMLAutoClose`) → извлечь текст, **стрипнуть теги**, **выхватить `<ruby>base<rt>reading</rt></ruby>`** (в тело — base, чтения — в §C). epub v1 = текст по spine (04-mvp); инлайн-разметку/вёрстку не сохраняем (осознанное ограничение v1).
- `TranslateBook` зовёт `Ingest` вместо `ReadFile+NormalizeSource`, потом `SplitChunks(doc.Chapters)`. Минимальный след в `runner.go` (замена 3 строк загрузки источника + персист ruby).

### C. Ruby-захват + персист (для потребления памятью v2)
- Захватывать `(base, reading, first_chapter)` при парсе epub. **НЕ инъектить в промпт** (это память v2 + детерминизм-load-bearing — см. §7d).
- Персист в **новую v4-таблицу** `ruby_readings(book_id, base, reading, first_chapter, …)` (PK напр. `(book_id, base, reading)`, `first_chapter`=MIN), идемпотентный upsert. Память v2 (шаг 4) это потребит → глоссарий-лок имён (D9). Форму таблицы согласуй, если сомнение (это контракт под шаг 4).
- **Куда лок НЕ делаем сейчас:** запись в глоссарий/лок рендеринга — шаг 4 (глоссарий-схемы ещё нет). D9 явно допускает ruby-лок как fast-follow.

---

## 3. Что НЕ делать в Шаге 3a (отложено — согласовано с владельцем)

- **Overlap-инъекция (read-only контекст соседних чанков).** `RenderVars` — ФИКСИРОВАННАЯ структура `{Book,Text,Draft}`, `Render` падает на неизвестном `{{…}}`. Overlap нельзя запечь в `Chunk.Text` — он потечёт в `EstimateTokens`/`maxTokens`, `msgsContentHash`, `RequestHash` И в **source coverage-гейта** (overlap в `Text` схлопнет `len_ratio` → ложный `excision_suspect`). Настоящий overlap = новое поле `RenderVars`/`Chunk` + плейсхолдер шаблона + правка `Messages()`/`runStage` — это **та же msgs-инъекционная поверхность, которую строит память v2** (Р5-раскладка: system → [глоссарий/STM/overlap] → user). Строим её ОДИН раз (шаг 4 / общий context-assembly), не дважды.
- **Ruby→глоссарий-лок** — нужен глоссарий (память v2). Сейчас только ЗАХВАТ+персист (§C).

Если по ходу окажется, что overlap критичен для приёмки раньше памяти — **короткий вопрос владельцу**, не тащи его в 3a молча.

---

## 4. Шов, который сохраняешь (контракт цикла)

- `TranslateBook`: `Ingest` → `chunks := SplitChunks(doc.Chapters)` → `for _, ch := range chunks { translateChunk(ctx, snapID, ch) }`. Ошибка если чанков 0.
- `translateChunk(ctx, snapID, ch Chunk)` сеет `out.Chapter/ChunkIdx` из `ch`, крутит стадии; **`ch.Text` идёт в ТРИ места** — не сломай ни одно:
  1. `Messages(..., RenderVars{Book, Text: ch.Text, Draft: prev})` → `{{text}}` на проводе.
  2. `classifyOutput(role, ch.Text, output, finish)` → **`ch.Text` = source coverage-гейта** (потому overlap НЕ в `Text`).
  3. `RequestHash(bookID, ch.Chapter, ch.ChunkIdx, attempt, …)` → ключ чекпоинта.
- `prev` = вывод ПРЕДЫДУЩЕЙ СТАДИИ (draft→edit), НЕ соседнего чанка. Первая стадия — `prev=""`.
- Новые границы → новые `RequestHash`/эскалационный `fbHash`: **безопасно, если бампнул `chunkerVersion`** (`--resnapshot`-гейт пере-пинит, старые чекпоинты/эскалации не false-хитят). `EscalationSpentUSD` суммирует per-book, границе-независимо. Спец-обработки не надо сверх бампа версии.

---

## 5. Инварианты (жёстко)

- **Детерминизм священен.** Чанкер — чистая функция; `chunkerVersion` бампнут; целевой размер/параметры — либо const (покрыт версией), либо конфиг → **фолдить в snapshot** как `coverageSnap` фолдит пороги гейта (не полагаться только на строку версии — находка внешнего ревью Вехи 2.5). Ruby, ЕСЛИ бы инъектился в промпт (не в 3a), был бы детерминизм-load-bearing.
- **Деньги.** 3a не делает боевых LLM-вызовов (импорт/чанкинг — оффлайн; тестируй на моках + `tmctl report` $0). Не сломай reserve/settle/эскалацию/F3.
- **Границы Р2/§3.4.** Чанкер — код (правила сегментации), версионируется `chunkerVersion`. Перечанкование инвалидирует TM by design (это плата за корректность, не баг).
- **disposition-контракт Вехи 2 и escalation/coverage Вехи 2.5 не ломать** (`jobs.status`=инфра, gate off-by-default, эскалация off-by-default).
- **D12 caution:** «наивный параллелизм чанков» НЕ строить (гонка `Runner.clients` + «коммит терминов только на границе джоб»). Цикл остаётся последовательным.
- **Без хаков, без новых внешних зависимостей** (epub — stdlib zip+xml).

## 6. Правила проекта

- Метод: доки+код → валидация → код → **агентское селфревью на вехе** (адверсариальный многоагентный Workflow: дименсии → находки → независимая верификация CONFIRMED/REFUTED с конкретным сценарием инпут→неверный-выход → исправь подтверждённые + регресс-тесты, **мутационно-проверенные**: сломай — тест должен упасть) → сдаёшь оркестратору/владельцу.
- Дименсии селфревью Шага 3a (минимум): **детерминизм** (чанкер чист, `chunkerVersion`/параметры покрыты snapshot, перечанкование = громкий resnapshot); **сегментация** (границы предложений/абзацев корректны на zh/ja/en, чанки в коридоре ~1–2k, не режет предложение, не путается с coverage-`split_sentences`); **epub-парсинг** (spine-порядок, стрип тегов, entity, битый/пустой epub — fail-loud не паника; ruby-извлечение корректно, base в тело); **интеграция** (coverage-source = чистый `ch.Text` без ruby-мусора и без overlap; `ch.Text` в 3 местах цел; чанки `<500` симв. молча выключают coverage — задокументируй/флагни); **регрессии** (Веха 0/1/2/2.5 не сломаны; e2e мульти-глава epub→чанки→мок-перевод; resume $0; determinism-тест перечанкования).
- **Не редактировать** `docs/research/*`, `01/02-decisions` — правки только через `03-implementation-notes.md` и `PROGRESS.md` (## Бэкенд). **Не трогать/не коммитить** `eval/`, `docs/experiments/*`, `docs/research/*` (зоны Полигона/памяти; читать можно). **Не коммитить** `settings.local.json`, `*.db`.
- Коммиты: одно предложение на английском ≤30 слов, без трейлера `Co-Authored-By`, без многострочного тела. На `main` прямо (паттерн проекта), только `backend/` + `PROGRESS`, никогда `eval/`.
- PUML не рендерить в картинки (у владельца PlantUML-расширение VS Code).
- Автопамять проекта (`MEMORY.md` + provider-wiring-gotchas) — сверься, обнови по находкам.

## 7. Риски интеграции (проверено картой кода — читай внимательно)

- **(a) `RenderVars` — фикс-структура `{Book,Text,Draft}`**, `Render` падает на неизвестном `{{…}}`. Любая инъекция (overlap/ruby/память) требует нового поля + плейсхолдера + правки `Messages()` — это НЕ чистый seam-swap. В 3a этого не делаешь (§3); значит твой чанкер отдаёт только `Chunk.Text`, и ничего кроме сегментации в msgs не меняется.
- **(b) coverage-гейт берёт `ch.Text` как source и скипает чанки `< MinChunkChars(500)`** (коридоры калиброваны на 500–2500 симв.). Настоящий чанкер, дробящий на суб-500 чанки, **молча выключит excision-гейт** для них, а малый `nSrc` шумит `sent_cov`. Целься в ~1–2k токенов (не в мелочь); задокументируй границу. И держи overlap/ruby ВНЕ `ch.Text`.
- **(c) новые границы vs эскалация/resume** — безопасно при бампе `chunkerVersion` (§4). `EscalationSpentUSD` границе-независим.
- **(d) детерминизм-хуки:** `chunkerVersion` — ЕДИНСТВЕННОЕ snapshot-поле сегментации; отдельных полей под параметры чанкера НЕТ. Любая смена поведения (размер, ruby-правила, паковка) ОБЯЗАНА бампнуть версию, иначе тихий расходящийся ре-пэй. Если размер станет конфиг-настраиваемым — **фолдни в top-level snapshot** (как `coverageSnap`), не полагайся на строку версии. `msgsContentHash` поймает смену контента на resume, но только бамп `chunkerVersion` делает это ГРОМКИМ `--resnapshot`, а не тихим per-chunk ре-переводом.

## 8. Сборка/прогон + открытые вопросы

Из `backend/`: `export PATH="$HOME/sdk/go/bin:$PATH"; go build ./... / go vet ./... / go test ./... -race -count=1` — зелёные на каждом шаге. Тесты: юниты чанкера (сегментация/границы/детерминизм), epub-парсинг (собери синтетический epub-zip в памяти теста, `archive/zip`), ruby-извлечение, мульти-глава e2e на мок-провайдере, determinism (перечанкование = resnapshot), идемпотентный persist ruby. Файлы БД только на ext4 (не /mnt/c).

**Открытые (уточни ДО кода, если стоят переделки):**
- Разделитель глав для **txt** (сохранить `\f` ради обратной совместимости example vs heading-эвристика).
- Форма таблицы `ruby_readings` (это контракт под шаг 4 — согласуй с тем, как память v2 будет её читать; при сомнении спроси).
- Целевой размер чанка **const (покрыт `chunkerVersion`) vs конфиг (фолдить в snapshot)** — реши по §7d; если делаешь конфиг, обязательно snapshot-fold.
- Порядок vs память v2 (шаг 4): 3a изолирован, но overlap/ruby-лок ждут инъекционной поверхности памяти — не тащи их в 3a без отмашки.

Welcome to the backend. Валидируй старт, замени чанкер, дай импорт, захвати ruby — и держи msgs-поверхность нетронутой для памяти v2.
