// Package pipeline is the C-core runner: deterministic prompt rendering, // request hashing, chunk checkpoints and the stage loop. Фаза 0 — мини-раннер // C1 (draft→edit, один чанк); циклы по главам/чанкам, гейты и эскалация // нарастают здесь в Фазе 1 (Р2: это код раннера, не конфиг). package pipeline import ( "crypto/sha256" "encoding/binary" "encoding/hex" "fmt" "os" "strconv" "strings" "unicode" "golang.org/x/text/unicode/norm" "textmachine/backend/internal/config" "textmachine/backend/internal/llm" ) // render.go enforces the determinism invariant (03-implementation-notes §3.1): // the rendered prompt is a PURE function of (snapshot, committed prior // outputs, chunk). Запрещены timestamps/UUID; никаких map-итераций — только // фиксированный список плейсхолдеров. Проверяется тестом «два рендера // байт-в-байт идентичны». Ломается детерминизм → ломается request-hash → // resume пере-переводит и пере-оплачивает главу, а кэш DeepSeek (byte prefix // match) промахивается. // chunkerVersion versions the segmentation/ingestion rules (включая // нормализацию источника NormalizeSource, ingest-слой txt/epub и целевой размер // чанка targetChunkTokens); it is part of the snapshot, so re-chunking a book is // an explicit re-translation, not a silent cache miss (§3.4 — [НУЖНО РЕШЕНИЕ] // п.2в). Шаг 3a replaced the Веха-2 char-budget paragraph packer with the real // source segmenter (sentence-aware, ~1–2k-token packing over ingest-split // chapters — chunker.go/ingest.go), so the version bumps: any earlier project DB // re-pins loudly via the --resnapshot gate (a re-chunk is a deliberate // re-translation), never a silent divergent re-pay. NOTE: the chunk target is a // const covered by THIS string; if it ever becomes config-tunable it must ALSO be // folded into the top-level snapshot (like coverageSnap), never left to the // version alone (§7d). Bumped v3→v4 closing external-review 3a: invalid-UTF-8 is // now normalized once in NormalizeSource (#3), the ASCII sentence boundary honors // quote-depth (#4), and
inside no longer leaks a newline into the body // (#5) — all segmentation/ingest behaviour changes → a loud --resnapshot. const chunkerVersion = "chunker-v4-utf8-quotedepth" // estimatorVersion versions EstimateTokens: его выход входит в max_tokens и // через него в request-hash, поэтому перекалибровка весов — тоже явная // инвалидация через snapshot, а не тихий промах всех чекпоинтов (находка // ревью: code-only правка эстиматора пере-оплатила бы полкниги). const estimatorVersion = "estimator-v0" // maxTokensPolicyVersion versions the attempt→max_tokens scaling // (maxTokensForAttempt, disposition.go). attempt-0 budget is already covered by // estimatorVersion + defaults, but a change to the RETRY scaling would silently // shift the request_hash of every attempt≥1 (a missed checkpoint → re-pay on // retried chunks). Folding this version into the snapshot makes such a change a // loud --resnapshot instead — the same discipline as estimatorVersion, applied // to the regeneration axis (Веха 2). const maxTokensPolicyVersion = "maxtok-v1-double-per-attempt" // userSeparator splits a prompt template file into the system part and the // user part. Обе части — версионируемые шаблоны в prompts/ («Редакция» позже // правит файлы, не код). const userSeparator = "\n---USER---\n" // PromptTemplate is one loaded stage template. type PromptTemplate struct { System string User string SHA256 string // hash of the raw file — участвует в snapshot } // LoadPromptTemplate reads and splits a template file. func LoadPromptTemplate(path string) (*PromptTemplate, error) { raw, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("pipeline: read prompt %s: %w", path, err) } sum := sha256.Sum256(raw) parts := strings.SplitN(string(raw), userSeparator, 2) t := &PromptTemplate{System: strings.TrimSpace(parts[0]), SHA256: hex.EncodeToString(sum[:])} if len(parts) == 2 { t.User = strings.TrimSpace(parts[1]) } else { return nil, fmt.Errorf("pipeline: prompt %s lacks the %q separator between system and user parts", path, strings.TrimSpace(userSeparator)) } return t, nil } // RenderVars are the ONLY placeholders a template may use. A fixed struct, not // a map: map iteration order would randomize the render. type RenderVars struct { Book *config.Book Text string // the source chunk Draft string // prior stage output ("" on the first stage) } // Render substitutes placeholders in a SINGLE pass over the template: values // are inserted verbatim and never re-scanned, so literal «{{…}}» внутри // исходника или черновика LLM — это просто текст, а не маркер (находка ревью: // последовательный ReplaceAll подставлял черновик в «{{draft}}» из сорса и // падал на «{{TN: примечание}}» в выводе модели). Unknown {{…}} markers in // the TEMPLATE are a hard error — молчаливо пустой плейсхолдер хуже падения. func Render(tpl string, v RenderVars) (string, error) { // Lookup-only map (никакой итерации — детерминизм не страдает). vals := map[string]string{ "book_id": v.Book.BookID, "title": v.Book.Title, "source_lang": v.Book.SourceLang, "target_lang": v.Book.TargetLang, "genre": v.Book.Genre, "audience": v.Book.Audience, "venuti": strconv.FormatFloat(v.Book.Venuti, 'f', 2, 64), "honorifics": v.Book.Honorifics, "transcription": v.Book.Transcription, "footnotes": v.Book.Footnotes, "text": v.Text, "draft": v.Draft, } var b strings.Builder rest := tpl for { i := strings.Index(rest, "{{") if i < 0 { b.WriteString(rest) return b.String(), nil } b.WriteString(rest[:i]) rest = rest[i:] end := strings.Index(rest, "}}") if end < 0 { return "", fmt.Errorf("pipeline: unterminated placeholder %q in template", snippetStr(rest)) } name := rest[2:end] val, ok := vals[name] if !ok { return "", fmt.Errorf("pipeline: unknown placeholder {{%s}} in template", name) } b.WriteString(val) rest = rest[end+2:] } } func snippetStr(s string) string { if len(s) > 40 { return s[:40] + "…" } return s } // Messages builds the wire-neutral message list for a stage with no memory injection // (the Веха-0/2 behaviour). Layout по Р5: system (стабильный префикс, кэш-граница) → // user (волатильный хвост). Kept as the zero-injection convenience over // MessagesWithInjection so existing callers/tests are untouched. func Messages(tpl *PromptTemplate, v RenderVars) ([]llm.Message, error) { return MessagesWithInjection(tpl, v, "") } // MessagesWithInjection builds the stage messages with the memory bank v2 injection // surface (шаг 4 §C, orchestrator variant "а" — a code-assembled message, NOT a new // RenderVars placeholder, so the fixed template stays stable). Layout по Р5/A6: // // system(стабильный префикс, CacheBoundary) → [injection: глоссарий/STM] → user(волатильный хвост) // // The injection is its OWN message placed AFTER the cache boundary (which stays on the // stable system prefix — the DeepSeek byte-prefix cache still hits; Anthropic's removed, // no cache_control concern). It is NOT part of ch.Text, so it never leaks into the // coverage len_ratio or the {{text}} placeholder (the 3a ch.Text contract). Because the // injection is a message, it is folded into request_hash/content_hash automatically — a // changed injection is a changed request (a resumed chunk reproduces it deterministically // from the frozen bank). An empty injection yields the exact 2-message list of Messages. func MessagesWithInjection(tpl *PromptTemplate, v RenderVars, injection string) ([]llm.Message, error) { sys, err := Render(tpl.System, v) if err != nil { return nil, err } user, err := Render(tpl.User, v) if err != nil { return nil, err } msgs := make([]llm.Message, 0, 3) msgs = append(msgs, llm.Message{Role: "system", Content: sys, CacheBoundary: true}) if strings.TrimSpace(injection) != "" { msgs = append(msgs, llm.Message{Role: "system", Content: injection, CacheBoundary: false}) } msgs = append(msgs, llm.Message{Role: "user", Content: user}) return msgs, nil } // RequestHash is the checkpoint/resume key (§3.1): a stable hash of everything // that determines the call. The snapshot id freezes the volatile context, so // identical re-renders after a restart find their checkpoint and are neither // repeated nor re-billed. attempt — измерение регенераций (Фаза 1: гейт // провалился → attempt+1 → новый ключ; без него регенерация попадала бы в // только что провалившийся чекпоинт и мгновенно эскалировала в премиум). func RequestHash(bookID string, chapter, chunkIdx, attempt int, stage, role, model string, temperature float64, reasoning string, jsonOnly bool, maxTokens int, snapshotID string, msgs []llm.Message) string { h := sha256.New() // Каждое поле длина-префиксовано (8 байт LE) вместо NUL-разделителя: // контент с байтом \x00 больше не может сдвинуть границы полей или // подделать лишние сообщения (находка ревью). Версия v2 фиксирует смену // формата хеша. var lb [8]byte w := func(parts ...string) { for _, p := range parts { binary.LittleEndian.PutUint64(lb[:], uint64(len(p))) h.Write(lb[:]) h.Write([]byte(p)) } } w("tm-request-v2", bookID, strconv.Itoa(chapter), strconv.Itoa(chunkIdx), strconv.Itoa(attempt), stage, role, model, strconv.FormatFloat(temperature, 'f', -1, 64), reasoning, strconv.FormatBool(jsonOnly), strconv.Itoa(maxTokens), snapshotID) // Явный счётчик сообщений: длина списка — тоже часть ключа. binary.LittleEndian.PutUint64(lb[:], uint64(len(msgs))) h.Write(lb[:]) for _, m := range msgs { w(m.Role, m.Content, strconv.FormatBool(m.CacheBoundary)) } return hex.EncodeToString(h.Sum(nil)) } // msgsContentHash is a content signature of the rendered messages, INDEPENDENT // of attempt/max_tokens. It guards the chunk_status resume fast-path (runner.go): // the source bytes are NOT folded into the snapshot (only the chunker RULES and // the semantic brief are), so a positional chunk_status row must be re-validated // against the current rendered content — otherwise an edited source would serve // a stale, divergent translation with no cache miss (the determinism invariant's // named failure; self-review Веха 2). Length-prefixed like RequestHash so // content with NUL bytes cannot forge field/message boundaries. The template is // already snapshot-pinned, so this only has to catch source/draft edits. func msgsContentHash(msgs []llm.Message) string { h := sha256.New() var lb [8]byte w := func(s string) { binary.LittleEndian.PutUint64(lb[:], uint64(len(s))) h.Write(lb[:]) h.Write([]byte(s)) } w("tm-content-v1") binary.LittleEndian.PutUint64(lb[:], uint64(len(msgs))) h.Write(lb[:]) for _, m := range msgs { w(m.Role) w(m.Content) } return hex.EncodeToString(h.Sum(nil)) } // NormalizeSource canonicalizes the source chunk so the request-hash is stable // across editors and operating systems (находка ревью): strip a UTF-8 BOM, // CRLF/CR → LF, Unicode NFC, trim surrounding whitespace. Без этого файл, // сохранённый в другом редакторе (BOM) или на Windows (CRLF), давал бы иной // hash и молча пере-переводил бы уже оплаченную книгу. func NormalizeSource(s string) string { s = strings.TrimPrefix(s, "\uFEFF") // UTF-8 BOM s = strings.ReplaceAll(s, "\r\n", "\n") s = strings.ReplaceAll(s, "\r", "\n") // Replace invalid UTF-8 with U+FFFD exactly ONCE, here, so all downstream // segmentation sees byte-valid text. \u0411\u0435\u0437 \u044D\u0442\u043E\u0433\u043E \u0447\u0430\u043D\u043A\u0435\u0440 \u0440\u0430\u0441\u0445\u043E\u0434\u0438\u0442\u0441\u044F \u0441 \u0441\u0430\u043C\u0438\u043C \u0441\u043E\u0431\u043E\u0439: // \u0435\u0433\u043E []rune-\u043F\u0443\u0442\u044C \u043F\u043E \u043F\u0440\u0435\u0434\u043B\u043E\u0436\u0435\u043D\u0438\u044F\u043C (chunker.go) \u043C\u043E\u043B\u0447\u0430 \u0437\u0430\u043C\u0435\u043D\u044F\u043B \u0431\u044B \u0441\u0442\u0440\u0435\u0439-\u0431\u0430\u0439\u0442 \u043D\u0430 // U+FFFD, \u0430 \u0441\u0442\u0440\u043E\u043A\u043E\u0432\u044B\u0439 \u043F\u0443\u0442\u044C \u043F\u043E \u0430\u0431\u0437\u0430\u0446\u0430\u043C \u0441\u043E\u0445\u0440\u0430\u043D\u044F\u043B \u0431\u044B \u0441\u044B\u0440\u043E\u0439 \u2014 size-\u0437\u0430\u0432\u0438\u0441\u0438\u043C\u0430\u044F \u043F\u043E\u0440\u0447\u0430, // \u0442\u0435\u043A\u0443\u0449\u0430\u044F \u0432 {{text}} \u0438 request_hash (\u0432\u043D\u0435\u0448\u043D\u0435\u0435 \u0440\u0435\u0432\u044C\u044E #3). \u042D\u0442\u043E \u043F\u0440\u0430\u0432\u0438\u043B\u043E \u0438\u043D\u0436\u0435\u0441\u0442\u0430 \u2192 // \u043F\u043E\u043A\u0440\u044B\u0442\u043E chunkerVersion (\u043E\u0441\u043E\u0437\u043D\u0430\u043D\u043D\u0430\u044F \u0437\u0430\u043C\u0435\u043D\u0430, \u043D\u0435 \u0442\u0438\u0445\u0430\u044F). s = strings.ToValidUTF8(s, "\uFFFD") s = norm.NFC.String(s) return strings.TrimSpace(s) } // EstimateTokens is a cheap, deterministic token estimate for reservation // sizing and max_tokens derivation (NOT for billing — billing uses the API's // usage). Калибровка полигона: иероглиф ≈0.9 ток., русский/латиница ≈3 симв. // на токен. func EstimateTokens(s string) int { cjk, other := 0, 0 for _, r := range s { switch { case unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul): cjk++ case unicode.IsSpace(r): // whitespace mostly folds into neighbouring tokens default: other++ } } est := cjk + other/3 if est < 16 { est = 16 } return est }