package config import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "sort" "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 // YoPolicy is the brief's ё-policy for the cheap yofikator gate: auto (flag only inconsistency // — the same word both with ё and е) | all-yo | all-e. "" defaults to auto. A brief field: it // changes the style-flag verdict, so it is folded into BriefHash (a change re-pins the snapshot). YoPolicy string `yaml:"yo_policy"` // StyleAllowlist exempts surfaces from the translit-interjection blocklist (a per-project // allowlist, 04-unhappy §6): a character actually named «Ара» is not an untranslated filler. // Lower-cased on load. Verdict-affecting → also folded into BriefHash. StyleAllowlist []string `yaml:"style_allowlist"` // Wiring: paths are resolved relative to the book.yaml location. Pipeline string `yaml:"pipeline"` ModelsFile string `yaml:"models"` SourceFile string `yaml:"source_file"` // Фаза 0: один файл = один чанк // Encoding of the txt source: auto|utf8|gb18030 ("" defaults to auto). Real zh .txt are often // GB18030 (the 蛊真人 acceptance book is), which the auto-detect handles; declare it explicitly // to skip detection. Ignored for epub (its documents carry their own charset). See ingest.go. Encoding string `yaml:"encoding"` ProjectDB string `yaml:"project_db"` // default: .db рядом с book.yaml // GlossarySeed is the optional path to the manual glossary seed YAML (memory v2, // шаг 4). Its curated approved/draft terms + the classified ruby readings are the // deterministic inputs the glossary is REPLACED from each run; editing it changes the // materialized approved set → a loud --resnapshot (F1). Empty = ruby-seed only (or an // empty glossary → the injection is inert, a safe no-op). GlossarySeed string `yaml:"glossary_seed"` // LangpackRoot is the optional root of the language-data packs (configs/langpacks/, D39.15/16): the // miner reads configs/langpacks// (source morphology) + configs/langpacks/-/ (Palladius) // from here. Resolved relative to book.yaml. Empty ⇒ no pack (the miner is inert / W1.5 auto-continues); // set ⇒ the runner loads the pack in W0, failing LOUD when the pair's catalog dir exists but a file is // missing/corrupt, and folds pack.Version() into the snapshot so a pack edit is a loud --resnapshot (R1). LangpackRoot string `yaml:"langpack_root"` // MinedDelta is the optional path to the owner-curated mined-term delta YAML (the W1.5 sign artifact, // R1). Its terms are loaded as Source:"mined" (NOT Source:"seed"), so they fold into the ENRICHED bank // version but NOT the base — adding them moves only snapshot_W2 («переоплата ОДНА»). Same seedTerm // schema as glossary_seed; resolved relative to book.yaml. Empty = no mined terms. MinedDelta string `yaml:"mined_delta"` 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) b.GlossarySeed = resolve(b.GlossarySeed) b.LangpackRoot = resolve(b.LangpackRoot) b.MinedDelta = resolve(b.MinedDelta) 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.GlossarySeed != "" { if _, err := os.Stat(b.GlossarySeed); err != nil { bad("glossary_seed %s is not readable: %v", b.GlossarySeed, err) } } if b.MinedDelta != "" { if _, err := os.Stat(b.MinedDelta); err != nil { bad("mined_delta %s is not readable: %v", b.MinedDelta, err) } } if b.Encoding == "" { b.Encoding = "auto" } switch strings.ToLower(b.Encoding) { case "auto", "utf8", "utf-8", "gb18030", "gbk", "gb2312": default: bad("encoding %q is not supported (use auto|utf8|gb18030)", b.Encoding) } if b.YoPolicy == "" { b.YoPolicy = "auto" } switch b.YoPolicy { case "auto", "all-yo", "all-e": default: bad("yo_policy %q is not supported (use auto|all-yo|all-e)", b.YoPolicy) } for i, s := range b.StyleAllowlist { b.StyleAllowlist[i] = strings.ToLower(strings.TrimSpace(s)) } // Canonicalize (sort) the allowlist so a cosmetic reorder in book.yaml does not change BriefHash // → the snapshot → force an unnecessary re-pin (self-review: it is a set, consumed order- // independently in cheapGateConfig). sort.Strings(b.StyleAllowlist) 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"` YoPolicy string `json:"yo_policy"` StyleAllowlist []string `json:"style_allowlist"` }{b.BookID, b.Title, b.SourceLang, b.TargetLang, b.Genre, b.Audience, b.Adult, b.Venuti, b.Honorifics, b.Transcription, b.Footnotes, b.YoPolicy, b.StyleAllowlist} 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 }