textmachine/backend/internal/config/book.go

213 lines
9.4 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 — конфиг проекта книги (Фаза 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: <book_id>.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/<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"`
// 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
}