164 lines
6.2 KiB
Go
164 lines
6.2 KiB
Go
// 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
|
||
}
|