202 lines
8.5 KiB
Go
202 lines
8.5 KiB
Go
package config
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"gopkg.in/yaml.v3"
|
||
)
|
||
|
||
// pipeline.go loads the pipeline-core config (C1/C2… — Р2). Граница «конфиг vs
|
||
// код» зафиксирована: конфиг задаёт состав/порядок стадий, роль→модель, версии
|
||
// промптов, пороги гейтов, режим инъекции глоссария, эскалационные цепочки,
|
||
// fan-out N, токен-бюджеты сборки контекста и cache TTL. Циклы по
|
||
// главам/чанкам, ветвление по гейтам, механика эскалации/ретраев и правило
|
||
// «эскалация → re-gate → флаг» зашиты в раннер.
|
||
|
||
// Pipeline is the parsed pipeline-*.yaml.
|
||
type Pipeline struct {
|
||
Core string `yaml:"core"` // C0|C1|C2|C3
|
||
Version int `yaml:"version"`
|
||
Defaults PipelineDefaults `yaml:"defaults"`
|
||
Context ContextAssembly `yaml:"context"`
|
||
Retries Retries `yaml:"retries"`
|
||
Stages []Stage `yaml:"stages"`
|
||
Gates Gates `yaml:"gates"`
|
||
Escal Escalation `yaml:"escalation"`
|
||
Fanout Fanout `yaml:"fanout"`
|
||
}
|
||
|
||
// PipelineDefaults are cross-stage knobs.
|
||
type PipelineDefaults struct {
|
||
// MaxOutputRatio sizes max_tokens ≈ ratio × input tokens (токен-калибровка
|
||
// полигона: русский выход zh→ru ≈1.9× входа; дефолт с запасом).
|
||
MaxOutputRatio float64 `yaml:"max_output_ratio"`
|
||
MinMaxTokens int `yaml:"min_max_tokens"`
|
||
}
|
||
|
||
// ContextAssembly holds the prompt-layout budgets (Р5-раскладка: стабильный
|
||
// префикс / волатильный хвост — сборка Фазы 1; поля фиксируются сейчас, чтобы
|
||
// схема конфига не менялась под ногами «Редакции»).
|
||
type ContextAssembly struct {
|
||
GlossaryInjection string `yaml:"glossary_injection"` // selective | full_prefix (Р5: обе схемы)
|
||
GlossaryTokenBudget int `yaml:"glossary_token_budget"`
|
||
STMDepth int `yaml:"stm_depth"`
|
||
OverlapTokens int `yaml:"overlap_tokens"`
|
||
CacheTTL string `yaml:"cache_ttl"` // per-stage TTL override — Фаза 1
|
||
}
|
||
|
||
// Retries distinguishes the CONTENT retry (регенерация после провала гейта до
|
||
// эскалации) from transport retries (профиль в models.yaml) — спека их
|
||
// различает явно (валидация, линза 2 п.5).
|
||
type Retries struct {
|
||
RegenerateBeforeEscalate int `yaml:"regenerate_before_escalate"`
|
||
}
|
||
|
||
// Stage is one pipeline pass.
|
||
type Stage struct {
|
||
Name string `yaml:"name"`
|
||
Role string `yaml:"role"`
|
||
Model string `yaml:"model"`
|
||
Prompt string `yaml:"prompt"` // path to the versioned template file
|
||
PromptVersion string `yaml:"prompt_version"`
|
||
Temperature float64 `yaml:"temperature"`
|
||
Reasoning string `yaml:"reasoning"` // "", off, low, medium, high
|
||
}
|
||
|
||
// Gates is the QA-gate config skeleton (пороги полигона; исполнение — Фаза 1).
|
||
type Gates struct {
|
||
Coverage CoverageGate `yaml:"coverage"`
|
||
}
|
||
|
||
// CoverageGate v1 (Р7): метрика — символы без пробелов; нижняя граница —
|
||
// подозрение на вырезание, верхняя — аномалия; пороги из эксперимента 02.
|
||
type CoverageGate struct {
|
||
Enabled bool `yaml:"enabled"`
|
||
LenRatio map[string][]float64 `yaml:"len_ratio_bounds"` // "zh-ru": [low, high]
|
||
SentCovMin float64 `yaml:"sent_cov_min"`
|
||
MinChunkChars int `yaml:"min_chunk_chars"`
|
||
}
|
||
|
||
// Escalation holds named model chains; channel-aware filtering (Anthropic вне
|
||
// 18+ и т.д.) применяет раннер, не конфиг.
|
||
type Escalation struct {
|
||
Chains map[string][]string `yaml:"chains"`
|
||
BudgetUSD float64 `yaml:"budget_usd"` // адресный премиум-бюджет книги; формула — [НУЖНО РЕШЕНИЕ] п.1
|
||
}
|
||
|
||
// Fanout is the C2 skeleton: N候補 candidates per chunk (fan-out — механика
|
||
// раннера, N — конфиг).
|
||
type Fanout struct {
|
||
Candidates int `yaml:"candidates"`
|
||
}
|
||
|
||
// CheckRunnable rejects a config whose mechanics the Phase-0 runner does NOT
|
||
// implement — раздельно от schema-валидации (LoadPipeline), чтобы скелеты C2/C3
|
||
// парсились и валидировались как схема, но НЕ исполнялись молча. Без этого
|
||
// гейта запуск pipeline-c2.yaml прогнал бы стадии линейно: fanout.candidates
|
||
// игнорируется, вердикт судьи утёк бы в редактуру, а деньги Opus сгорели бы на
|
||
// мусор (находка ревью; Р2 требует закрывать дыру «конфиг vs код» fail-loud).
|
||
func (p *Pipeline) CheckRunnable() error {
|
||
switch p.Core {
|
||
case "C0", "C1":
|
||
default:
|
||
return fmt.Errorf("pipeline core %q не исполним в Фазе 0 (реализованы только C0/C1 — линейный проход стадий; C2/C3 selection/fusion — механика раннера Фазы 2)", p.Core)
|
||
}
|
||
if p.Fanout.Candidates > 1 {
|
||
return fmt.Errorf("pipeline fanout.candidates=%d не исполним в Фазе 0 (fan-out N кандидатов — механика раннера Фазы 2; C1 = один черновик)", p.Fanout.Candidates)
|
||
}
|
||
for _, st := range p.Stages {
|
||
if st.Role == "judge" {
|
||
return fmt.Errorf("pipeline stage %q role=judge не исполним в Фазе 0 (селектор получает N кандидатов — механика Фазы 2, а раннер гонит стадии линейно)", st.Name)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// LoadPipeline reads and validates a pipeline config against the models known
|
||
// to models.yaml.
|
||
func LoadPipeline(path string, models *Models) (*Pipeline, error) {
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("config: read %s: %w", path, err)
|
||
}
|
||
var p Pipeline
|
||
if err := yaml.Unmarshal(raw, &p); err != nil {
|
||
return nil, fmt.Errorf("config: parse %s: %w", path, err)
|
||
}
|
||
// Prompt paths resolve relative to the pipeline file's directory, so a
|
||
// project works from any CWD.
|
||
dir := filepath.Dir(path)
|
||
for i := range p.Stages {
|
||
if pr := p.Stages[i].Prompt; pr != "" && !filepath.IsAbs(pr) {
|
||
p.Stages[i].Prompt = filepath.Join(dir, pr)
|
||
}
|
||
}
|
||
|
||
var problems []string
|
||
bad := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) }
|
||
|
||
if p.Core == "" {
|
||
bad("core is required (C0|C1|C2|C3)")
|
||
}
|
||
if len(p.Stages) == 0 {
|
||
bad("at least one stage is required")
|
||
}
|
||
if p.Defaults.MaxOutputRatio <= 0 {
|
||
p.Defaults.MaxOutputRatio = 2.0
|
||
}
|
||
if p.Defaults.MinMaxTokens <= 0 {
|
||
p.Defaults.MinMaxTokens = 2048
|
||
}
|
||
if p.Fanout.Candidates <= 0 {
|
||
p.Fanout.Candidates = 1
|
||
}
|
||
switch p.Context.GlossaryInjection {
|
||
case "", "selective", "full_prefix":
|
||
default:
|
||
bad("context.glossary_injection must be selective|full_prefix, got %q", p.Context.GlossaryInjection)
|
||
}
|
||
seen := map[string]bool{}
|
||
for i, st := range p.Stages {
|
||
if st.Name == "" {
|
||
bad("stage %d: name is required", i)
|
||
}
|
||
if seen[st.Name] {
|
||
bad("stage %q: duplicate name", st.Name)
|
||
}
|
||
seen[st.Name] = true
|
||
if st.Role == "" {
|
||
bad("stage %q: role is required", st.Name)
|
||
}
|
||
if st.PromptVersion == "" {
|
||
bad("stage %q: prompt_version is required (версионирование промптов — Р2)", st.Name)
|
||
}
|
||
if _, ok := models.Models[st.Model]; !ok {
|
||
bad("stage %q: model %q is not defined in models.yaml", st.Name, st.Model)
|
||
}
|
||
if st.Prompt == "" {
|
||
bad("stage %q: prompt template path is required", st.Name)
|
||
} else if _, err := os.Stat(st.Prompt); err != nil {
|
||
bad("stage %q: prompt template %s is not readable: %v", st.Name, st.Prompt, err)
|
||
}
|
||
switch st.Reasoning {
|
||
case "", "off", "low", "medium", "high":
|
||
default:
|
||
bad("stage %q: reasoning must be off|low|medium|high, got %q", st.Name, st.Reasoning)
|
||
}
|
||
}
|
||
for chain, ms := range p.Escal.Chains {
|
||
for _, m := range ms {
|
||
if _, ok := models.Models[m]; !ok {
|
||
bad("escalation chain %q: model %q is not defined in models.yaml", chain, m)
|
||
}
|
||
}
|
||
}
|
||
if len(problems) > 0 {
|
||
return nil, fmt.Errorf("config %s:\n - %s", path, strings.Join(problems, "\n - "))
|
||
}
|
||
return &p, nil
|
||
}
|