textmachine/backend/internal/config/book.go

145 lines
5.7 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 config
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// book.go: translation brief — конфиг проекта книги (Фаза 0 плана: языковая
// пара, жанр, аудитория, 18+, слайдер Venuti, хонорифики, транскрипция,
// сноски). brief_hash входит в ключ TM и в snapshot: изменение брифа посреди
// книги — явная команда с показом стоимости пере-перевода (Р6).
// Book is the parsed book.yaml.
type Book struct {
BookID string `yaml:"book_id"`
Title string `yaml:"title"`
SourceLang string `yaml:"source_lang"` // zh | ja | en
TargetLang string `yaml:"target_lang"` // ru
Genre string `yaml:"genre"`
Audience string `yaml:"audience"`
// Adult: интерим-правило 18+ Фазы 1 — true принудительно исключает
// Anthropic из роутинга книги (эскалация без Opus/Sonnet).
Adult bool `yaml:"adult"`
// Venuti is the foreignization↔domestication slider, 0.0 (доместикация)
// … 1.0 (форенизация).
Venuti float64 `yaml:"venuti"`
Honorifics string `yaml:"honorifics"` // keep | adapt
Transcription string `yaml:"transcription"` // e.g. polivanov | palladius
Footnotes string `yaml:"footnotes"` // none | minimal | rich
// Wiring: paths are resolved relative to the book.yaml location.
Pipeline string `yaml:"pipeline"`
ModelsFile string `yaml:"models"`
SourceFile string `yaml:"source_file"` // Фаза 0: один файл = один чанк
ProjectDB string `yaml:"project_db"` // default: <book_id>.db рядом с book.yaml
Ceilings BookCap `yaml:"ceilings"`
}
// BookCap are the ledger admission limits (Р7: потолок $ на книгу/день).
type BookCap struct {
BookUSD float64 `yaml:"book_usd"`
DayUSD float64 `yaml:"day_usd"`
}
// LoadBook reads and validates book.yaml, resolving relative paths against
// its directory.
func LoadBook(path string) (*Book, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("config: read %s: %w", path, err)
}
var b Book
if err := yaml.Unmarshal(raw, &b); err != nil {
return nil, fmt.Errorf("config: parse %s: %w", path, err)
}
dir := filepath.Dir(path)
resolve := func(p string) string {
if p == "" || filepath.IsAbs(p) {
return p
}
return filepath.Join(dir, p)
}
b.Pipeline = resolve(b.Pipeline)
b.ModelsFile = resolve(b.ModelsFile)
b.SourceFile = resolve(b.SourceFile)
if b.ProjectDB == "" {
b.ProjectDB = filepath.Join(dir, b.BookID+".db")
} else {
b.ProjectDB = resolve(b.ProjectDB)
}
var problems []string
bad := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) }
if b.BookID == "" {
bad("book_id is required")
}
if b.SourceLang == "" || b.TargetLang == "" {
bad("source_lang and target_lang are required")
}
if b.Venuti < 0 || b.Venuti > 1 {
bad("venuti must be in [0,1], got %v", b.Venuti)
}
if b.Pipeline == "" {
bad("pipeline config path is required")
}
if b.ModelsFile == "" {
bad("models config path is required")
}
if b.SourceFile == "" {
bad("source_file is required")
} else if _, err := os.Stat(b.SourceFile); err != nil {
bad("source_file %s is not readable: %v", b.SourceFile, err)
}
if b.Ceilings.BookUSD <= 0 && b.Ceilings.DayUSD <= 0 {
bad("ceilings: at least one of book_usd/day_usd must be set (ledger без потолка запрещён Р7)")
}
if len(problems) > 0 {
return nil, fmt.Errorf("config %s:\n - %s", path, strings.Join(problems, "\n - "))
}
return &b, nil
}
// BriefHash is the canonical hash of the SEMANTIC brief fields — the ones
// whose change legitimately invalidates the TM (Р6: brief_hash входит в ключ
// TM). Wiring fields (paths, ceilings, db) deliberately excluded: перенос
// проекта в другую директорию не должен пере-переводить книгу. JSON с
// фиксированным порядком полей → детерминированный байтовый рендер.
func (b *Book) BriefHash() string {
// Title входит в бриф: это смысловое поле (используется промптами через
// {{title}} и попадает в рендер → request-hash), не wiring-идентификатор.
// Без него правка заголовка молча инвалидировала бы чекпоинты в обход
// snapshot-гейта (находка ревью).
canon := struct {
BookID string `json:"book_id"`
Title string `json:"title"`
SourceLang string `json:"source_lang"`
TargetLang string `json:"target_lang"`
Genre string `json:"genre"`
Audience string `json:"audience"`
Adult bool `json:"adult"`
Venuti float64 `json:"venuti"`
Honorifics string `json:"honorifics"`
Transcription string `json:"transcription"`
Footnotes string `json:"footnotes"`
}{b.BookID, b.Title, b.SourceLang, b.TargetLang, b.Genre, b.Audience, b.Adult, b.Venuti, b.Honorifics, b.Transcription, b.Footnotes}
data, err := json.Marshal(canon)
if err != nil {
// A struct of scalars cannot fail to marshal; keep the signature clean.
panic(fmt.Sprintf("config: brief hash marshal: %v", err))
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
// LangPair returns "ja-ru"-style pair key (ключи порогов coverage-гейта).
func (b *Book) LangPair() string {
return b.SourceLang + "-" + b.TargetLang
}