textmachine/backend/internal/pipeline/runner.go

168 lines
6.6 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"
"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 monolingual editor stage (D1): it receives the draft plus the approved
// glossary's dst forms as target-consistency constraints, never the source.
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
// 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 {
for _, st := range r.Pipeline.Stages {
tpl, err := LoadPromptTemplate(st.Prompt)
if err != nil {
return err
}
r.templates[st.Name] = tpl
}
return nil
}
func (r *Runner) client(model string) (llm.LLMClient, error) {
if c, ok := r.clients[model]; ok {
return c, nil
}
c, err := BuildClient(r.Models, model, r.Log)
if err != nil {
return nil, err
}
r.clients[model] = c
return c, nil
}