textmachine/backend/internal/config/book.go

233 lines
10 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"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// book.go: translation brief — the book project config (Phase 0 of the plan: language
// pair, genre, audience, 18+, Venuti slider, honorifics, transcription,
// footnotes). brief_hash is part of the TM key and of the snapshot: changing the brief mid-
// book is an explicit command that shows the cost of re-translation (Р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: the interim 18+ rule of Phase 1 — true forcibly excludes
// Anthropic from the book's routing (escalation without Opus/Sonnet).
Adult bool `yaml:"adult"`
// Venuti is the foreignization↔domestication slider, 0.0 (domestication)
// … 1.0 (foreignization).
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"` // Phase 0: one file = one chunk
// 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: <book_id>.db next to book.yaml
// GlossarySeed is the optional path to the manual glossary seed YAML (memory v2,
// step 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/<src>/ (source morphology) + configs/langpacks/<src>-<tgt>/ (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"`
// LangpackExtend is the optional root of a BOOK-SCOPED langpack overlay (pair-14 §1): a book's PRIVATE
// canon — a clan surname (古月 in 蛊真人 is a book clan, not a 百家姓 family), a sect term — that must reach
// the miner FOR THIS BOOK without polluting the shared pair langpack. Same layout as langpack_root; only
// the files a book ships are present, each UNIONED onto the shared pack (additive) with its bytes folded
// into pack.Version() (a book-canon edit is a loud --resnapshot for this book only). Resolved relative to
// book.yaml. Empty ⇒ no overlay (the shared pack is used as-is).
LangpackExtend string `yaml:"langpack_extend"`
// 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 (the pay-once invariant). Same seedTerm
// schema as glossary_seed; resolved relative to book.yaml. Empty = no mined terms.
MinedDelta string `yaml:"mined_delta"`
// MinedRejects is the optional path to the owner's mined-term REJECT list YAML (R1-FL-B). It lists the
// src surfaces the owner reviewed and DECLINED, so the bank-mining stop stops re-proposing them every
// run (else a declined term re-fires the stop forever — a livelock). Unlike MinedDelta it is a PROPOSAL
// filter only: rejects NEVER enter the bank content, so they are deliberately NOT folded into the
// snapshot. Resolved relative to book.yaml. Empty = no rejects.
MinedRejects string `yaml:"mined_rejects"`
Ceilings BookCap `yaml:"ceilings"`
}
// BookCap are the ledger admission limits (Р7: the $ ceiling per book/day).
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.LangpackExtend = resolve(b.LangpackExtend)
b.MinedDelta = resolve(b.MinedDelta)
b.MinedRejects = resolve(b.MinedRejects)
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.MinedRejects != "" {
if _, err := os.Stat(b.MinedRejects); err != nil {
bad("mined_rejects %s is not readable: %v", b.MinedRejects, 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 (a ledger with no ceiling is forbidden, Р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 is part of the
// TM key). Wiring fields (paths, ceilings, db) deliberately excluded: moving
// the project into another directory must not re-translate the book. JSON with
// a fixed field order → a deterministic byte render.
func (b *Book) BriefHash() string {
// Title is part of the brief: it is a semantic field (used by prompts via
// {{title}} and lands in the render → request-hash), not a wiring identifier.
// Without it, editing the title would silently invalidate checkpoints, bypassing
// the snapshot gate (review finding).
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 (the coverage-gate threshold keys).
func (b *Book) LangPair() string {
return b.SourceLang + "-" + b.TargetLang
}