textmachine/backend/internal/pipeline/render.go

218 lines
9.3 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); it is part of the snapshot, so
// re-chunking a book is an explicit re-translation, not a silent cache miss
// (§3.4 — [НУЖНО РЕШЕНИЕ] п.2в).
const chunkerVersion = "chunker-v1-wholefile-nfc"
// estimatorVersion versions EstimateTokens: его выход входит в max_tokens и
// через него в request-hash, поэтому перекалибровка весов — тоже явная
// инвалидация через snapshot, а не тихий промах всех чекпоинтов (находка
// ревью: code-only правка эстиматора пере-оплатила бы полкниги).
const estimatorVersion = "estimator-v0"
// 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. Layout по Р5:
// system (стабильный префикс, кэш-граница на последнем стабильном блоке) →
// user (волатильный хвост: чанк/черновик). Инъекция глоссария/STM встанет
// между ними в Фазе 1, не меняя схемы.
func Messages(tpl *PromptTemplate, v RenderVars) ([]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
}
return []llm.Message{
{Role: "system", Content: sys, CacheBoundary: true},
{Role: "user", Content: user},
}, 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))
}
// 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")
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
}