// 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/hex" "fmt" "os" "strconv" "strings" "unicode" "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 rules; 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-v0-wholefile" // 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. Unknown {{…}} markers are a hard error — // молчаливо пустой плейсхолдер в промпте хуже падения. func Render(tpl string, v RenderVars) (string, error) { pairs := [...][2]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}, } out := tpl for _, p := range pairs { out = strings.ReplaceAll(out, p[0], p[1]) } if i := strings.Index(out, "{{"); i >= 0 { end := strings.Index(out[i:], "}}") if end < 0 { end = len(out) - i } else { end += 2 } return "", fmt.Errorf("pipeline: unknown placeholder %q in template", out[i:i+end]) } return out, nil } // 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. func RequestHash(bookID string, chapter, chunkIdx int, stage, role, model string, temperature float64, reasoning string, jsonOnly bool, maxTokens int, snapshotID string, msgs []llm.Message) string { h := sha256.New() w := func(parts ...string) { for _, p := range parts { h.Write([]byte(p)) h.Write([]byte{0}) } } w("tm-request-v1", bookID, strconv.Itoa(chapter), strconv.Itoa(chunkIdx), stage, role, model, strconv.FormatFloat(temperature, 'f', -1, 64), reasoning, strconv.FormatBool(jsonOnly), strconv.Itoa(maxTokens), snapshotID) for _, m := range msgs { w(m.Role, m.Content, strconv.FormatBool(m.CacheBoundary)) } return hex.EncodeToString(h.Sum(nil)) } // 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 }