textmachine/backend/internal/pipeline/render.go

331 lines
17 KiB
Go
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.

// 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, ~12k-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 <br> inside <ruby> no longer leaks a newline into the body
// (#5) — all segmentation/ingest behaviour changes → a loud --resnapshot.
// Bumped v4→v5 (WS2): the packing budget moved from input-token heuristic (const 1500) to OUTPUT
// (ru) tokens via fertility (est_out), draft chunks decoupled from coarse EDIT units (grouped whole
// chunks to EditCeilingOut), oversized-sentence flag added. The budget itself is ALSO folded via
// segmentationSnap (snapshot.go), so this version covers only the algorithm shape.
// Ш-2 EXTENSION (see memnorm.go; beyond the owner-named memnorm/classifier/style set — justified: same
// silent-drift class): tokenClassCounts (chunker.go) classifies source runes via unicode.Han/Hiragana/
// Katakana/Hangul to compute est_out, which SETS chunk boundaries → the wire. A toolchain Unicode bump that
// reclassifies a rune would silently re-chunk the book; weaving unicode.Version makes it a loud --resnapshot.
const chunkerVersion = "chunker-v5-output-budget-editunit+u" + unicode.Version
// estimatorVersion versions EstimateTokens: его выход входит в max_tokens и
// через него в request-hash, поэтому перекалибровка весов — тоже явная
// инвалидация через snapshot, а не тихий промах всех чекпоинтов (находка
// ревью: code-only правка эстиматора пере-оплатила бы полкниги).
// Ш-2 EXTENSION (see memnorm.go): EstimateTokens classifies runes via the same unicode ranges to size
// max_tokens (∈ request_hash), so a toolchain Unicode reclassification shifts the wire — folded loudly.
const estimatorVersion = "estimator-v0+u" + unicode.Version
// 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"
// fewShotSeparator optionally splits the SYSTEM part into a core prefix and a
// trailing few-shot example block (D38.4). A stage may drop the block (few_shot:false)
// for a model whose own CoT is disrupted by hand examples (deepseek-thinking, exp14 §2а).
// Absent → the whole system part is core; the toggle is a no-op.
const fewShotSeparator = "\n---FEWSHOT---\n"
// PromptTemplate is one loaded stage template. System is the core system prefix;
// FewShot is the optional example block (empty when the file has no ---FEWSHOT---).
type PromptTemplate struct {
System string
FewShot string
User string
SHA256 string // hash of the raw file — участвует в snapshot
}
// LoadPromptTemplate reads and splits a template file into core-system / few-shot / user.
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)
if len(parts) != 2 {
return nil, fmt.Errorf("pipeline: prompt %s lacks the %q separator between system and user parts", path, strings.TrimSpace(userSeparator))
}
t := &PromptTemplate{User: strings.TrimSpace(parts[1]), SHA256: hex.EncodeToString(sum[:])}
// The few-shot block, if present, is the tail of the SYSTEM part (before ---USER---).
head := strings.SplitN(parts[0], fewShotSeparator, 2)
t.System = strings.TrimSpace(head[0])
if len(head) == 2 {
t.FewShot = strings.TrimSpace(head[1])
}
return t, nil
}
// SystemFor returns the effective system prompt for a stage: the core prefix, plus the
// few-shot block when the stage keeps it on (the default). The whole raw file is still
// snapshot-pinned via SHA256, and the few_shot on/off state is folded separately, so
// dropping the block is a loud --resnapshot, not a silent divergence.
func (t *PromptTemplate) SystemFor(fewShotOn bool) string {
if fewShotOn && t.FewShot != "" {
return t.System + "\n\n" + t.FewShot
}
return t.System
}
// fewShotEnabled resolves a stage's few_shot toggle: absent (nil) defaults to ON, so
// every existing config keeps the few-shot examples without touching its yaml.
func fewShotEnabled(st config.Stage) bool {
return st.FewShot == nil || *st.FewShot
}
// 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 (stagerun.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
}