textmachine/backend/internal/pipeline/runner.go

234 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 pipeline
import (
"errors"
"fmt"
"log/slog"
"os"
"textmachine/backend/internal/config"
"textmachine/backend/internal/ledger"
"textmachine/backend/internal/llm"
"textmachine/backend/internal/store"
)
// runner.go: сетап раннера — Runner и его wiring (конфиг-стек, store в нужном
// режиме владения, кэши клиентов/шаблонов). Сама машинерия книги разнесена по
// связным файлам (пакет №4): снапшот — snapshot.go, сид/ruby — seeding.go, цикл
// книги — bookrun.go, disposition-петля чанка — chunkrun.go, исполнение стадии
// с деньгами (reserve → call → settle+checkpoint) — stagerun.go, single-hop
// эскалация с ре-гейтом — escalation.go, $0-resume решённого — resume.go.
// roleTranslator is the stage role whose output is a direct source→target
// translation — the only role the excision coverage-gate is meaningful on.
const roleTranslator = "translator"
// roleEditor is the BILINGUAL editor stage (D30.1 supersede of D1): editor.md feeds it BOTH the
// source and the draft, plus the approved glossary's CONFIRMED dst forms as target-consistency
// constraints. (The stale "monolingual, never sees the source" framing is D1, fully superseded.)
const roleEditor = "editor"
// Runner executes a book's pipeline.
type Runner struct {
Book *config.Book
Models *config.Models
Pipeline *config.Pipeline
Store *store.Store
Pricer *ledger.Pricer
Log *slog.Logger
// Resnapshot re-pins existing jobs to the CURRENT snapshot when configs/
// prompts changed since the job started (tmctl --resnapshot). Без флага
// расхождение — громкая ошибка: смена контекста инвалидирует чекпоинты и
// пере-оплачивает вызовы, это делается только осознанно (Р6).
Resnapshot bool
clients map[string]llm.LLMClient
templates map[string]*PromptTemplate
// rateGuards is the per-model wave-concurrency guard set (WS1 §1б), built once in W0 and
// read-only in the waves — a transport axis, never snapshot-folded. nil until buildRateGuards.
rateGuards map[string]*rateGuard
// memory is the book's glossary FROZEN for this job (materialized once in
// TranslateBook, after seeding, before snapshotID). Its Version() is the F1
// content-hash folded into the snapshot; its Select drives per-chunk injection.
// nil until materialized (report path / no glossary) → memoryVersion() falls back
// to the empty-materialization hash, a stable constant.
memory *MemoryBank
}
// NewRunner loads all three configs rooted at book.yaml and opens the project
// store. Caller owns Close.
func NewRunner(bookPath string, logger *slog.Logger) (*Runner, error) {
return openRunner(bookPath, logger, true)
}
// NewReadOnlyRunner builds a runner for the $0 read-only commands (`tmctl report`/`status`): it
// SKIPS the API-key preflight so an auditor WITHOUT provider keys can read the store (D20.4). Those
// commands make zero LLM calls (they only project the persisted rows / re-chunk the source), so a
// missing key is irrelevant — the config-mechanics and adult-channel lints still run. translate and
// redrive (which DO call providers) keep NewRunner.
//
// The store opens READ-ONLY and WITHOUT the exclusive flock (store.OpenReadOnly), so
// status/report work DURING a live translate — раньше оператор был заперт от них на всё
// время прогона (боль smoke-прогона пакета №4). Первое касание проекта (файла БД ещё
// нет) падает обратно на полный Open: создать+мигрировать пустую базу — прежнее
// поведение «status до первого прогона показывает 0/N pending», и живого писателя,
// с которым можно столкнуться, в этот момент не существует.
func NewReadOnlyRunner(bookPath string, logger *slog.Logger) (*Runner, error) {
return openRunner(bookPath, logger, false)
}
// openRunner loads the config stack and opens the store. forWrite selects the call
// path: translate/redrive (провайдерские ключи обязательны, store во владение —
// flock+миграции+recovery) против status/report (без ключей, store read-only).
func openRunner(bookPath string, logger *slog.Logger, forWrite bool) (*Runner, error) {
book, err := config.LoadBook(bookPath)
if err != nil {
return nil, err
}
models, err := config.LoadModels(book.ModelsFile)
if err != nil {
return nil, err
}
pipe, err := config.LoadPipeline(book.Pipeline, models)
if err != nil {
return nil, err
}
// Fail-fast ДО открытия store (взятия flock и side-effect'ов): исполнима
// ли механика конфига и заданы ли ключи используемых моделей.
if err := pipe.CheckRunnable(); err != nil {
return nil, err
}
if err := pipe.CheckAdultChannel(book.Adult); err != nil {
return nil, err
}
// Key preflight only for the call paths that actually reach a provider — read-only report/status
// are $0 (D20.4). CheckRunnable/CheckAdultChannel above stay unconditional (they cost nothing and
// catch a broken config even on a read).
if forWrite {
if err := models.CheckKeys(pipe); err != nil {
return nil, err
}
}
pricer, err := models.Prices()
if err != nil {
return nil, err
}
var st *store.Store
if forWrite {
// Write path (translate/redrive): exclusive owner — flock, migrations,
// reservation recovery.
st, err = store.Open(book.ProjectDB)
} else {
// Read path (status/report): no flock — работает при живом прогоне; на
// первом касании проекта (базы ещё нет) — полный Open (создать+мигрировать).
st, err = store.OpenReadOnly(book.ProjectDB)
if err != nil && errors.Is(err, os.ErrNotExist) {
st, err = store.Open(book.ProjectDB)
}
}
if err != nil {
return nil, err
}
r := &Runner{
Book: book,
Models: models,
Pipeline: pipe,
Store: st,
Pricer: pricer,
Log: logger,
clients: map[string]llm.LLMClient{},
templates: map[string]*PromptTemplate{},
}
if err := r.loadTemplates(); err != nil {
st.Close()
return nil, err
}
return r, nil
}
func (r *Runner) Close() error { return r.Store.Close() }
func (r *Runner) loadTemplates() error {
pair := r.Book.LangPair()
for _, st := range r.Pipeline.Stages {
// Resolve the stage's prompt from the pair-keyed pack by the book's language pair (D39 слой 2):
// a legacy single `prompt` is pair-agnostic; a `prompts` pack fails LOUD on a missing pair here
// (a ja book against a zh-ru-only pack stops instead of silently running Chinese conventions).
promptPath, err := st.PromptPathFor(pair)
if err != nil {
return err
}
tpl, err := LoadPromptTemplate(promptPath)
if err != nil {
return err
}
// Resolve the effective system once per stage: fold the few-shot block into
// System unless the stage switched it off (D38.4). FewShot is retained so the
// snapshot can fold the on/off state only for stages that actually have a block.
tpl.System = tpl.SystemFor(fewShotEnabled(st))
r.templates[st.Name] = tpl
}
return nil
}
// segBudget resolves the pipeline's config.Segmentation into the pipeline-package SegBudget the
// chunker consumes (WS2). Kept in the runner (not chunker.go) so the segmenter stays config-free.
func (r *Runner) segBudget() SegBudget {
seg := r.Pipeline.Segmentation
return SegBudget{
DraftBudgetOut: float64(seg.DraftBudgetOut),
EditCeilingOut: float64(seg.EditCeilingOut),
FertCJK: seg.Fertility.CJK,
FertOther: seg.Fertility.Other,
}
}
// reachableModels enumerates every model a run can call: the union of the stage models and their
// single-hop escalate_to fallbacks (stagerun.go calls r.client ONLY with model∈{st.Model, EscalateTo}).
// This set is complete and static for a book, which is what lets W0 pre-build every client (a third
// model axis — channel B / annotator — must extend this to stay race-free). Deterministic order.
func (r *Runner) reachableModels() []string {
seen := map[string]bool{}
var out []string
add := func(m string) {
if m != "" && !seen[m] {
seen[m] = true
out = append(out, m)
}
}
for _, st := range r.Pipeline.Stages {
add(st.Model)
add(st.EscalateTo)
}
return out
}
// buildClients EAGER-constructs every reachable LLM client BEFORE any wave goroutine starts (W0).
// After this the clients map is READ-ONLY in the waves, so r.client is lock-free and a miss is a
// loud error, not a lazy build under a data race — closing the runner.go lazy-init race (D12) and
// tripwiring a future un-enumerated model axis. Idempotent; BuildClient needs only the provider
// config (not the API key), so eager-build is safe even on a key-less resume.
func (r *Runner) buildClients() error {
for _, m := range r.reachableModels() {
if _, ok := r.clients[m]; ok {
continue
}
c, err := BuildClient(r.Models, m, r.Log)
if err != nil {
return fmt.Errorf("pipeline: eager-build client for model %q (W0): %w", m, err)
}
r.clients[m] = c
}
return nil
}
// client returns the PRE-BUILT client for a model. Read-only (no lazy build, no lock): buildClients
// constructed every reachable client in W0, so the map is only read in the waves; a miss means an
// un-enumerated model reached the wire and is a loud error, never a silent lazy build under a race.
func (r *Runner) client(model string) (llm.LLMClient, error) {
if c, ok := r.clients[model]; ok {
return c, nil
}
return nil, fmt.Errorf("pipeline: no pre-built client for model %q — W0 buildClients enumerates stage models escalate_to; a model outside that set reached the wire, add it to the eager set to keep the waves race-free", model)
}