213 lines
9.4 KiB
Go
213 lines
9.4 KiB
Go
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
|
||
}
|