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: .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 { canon := struct { BookID string `json:"book_id"` 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.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 }