textmachine/backend/cmd/tmctl/main.go

158 lines
5.7 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.

// tmctl is the TextMachine CLI: translate / report / status / redrive.
// main.go — тонкая обвязка (пакет №4): разбор аргументов — invocation.go,
// рендеры вывода — render.go, .env — dotenv.go; здесь только связка
// «parse → env → ctx → fetch → render» и маппинг exit-кодов.
package main
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"textmachine/backend/internal/obs"
"textmachine/backend/internal/pipeline"
"textmachine/backend/internal/store"
)
func main() {
err := run()
if err != nil {
fmt.Fprintln(os.Stderr, "tmctl:", err)
}
os.Exit(exitCode(err))
}
// exitCode maps a run() error onto the ratified shell contract (Веха 2): 0 clean ·
// 2 completed-with-flags (приёмка допускает N флагов; типизированный сентинел
// *pipeline.CompletedWithFlags, переживает %w-обёртки через errors.As) · 1 infra
// failure и всё остальное, включая ошибку разбора флагов (ContinueOnError в
// parseInvocation держит 2 эксклюзивным для флагнутых чанков).
func exitCode(err error) int {
var flagged *pipeline.CompletedWithFlags
switch {
case err == nil:
return 0
case errors.As(err, &flagged):
return 2
default:
return 1
}
}
func run() error {
inv, err := parseInvocation(os.Args[1:], os.Stderr)
if err != nil {
return err
}
loadDotEnv(filepath.Join(filepath.Dir(inv.cfgPath), ".env"))
loadDotEnv(".env")
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// LogBodies решается один раз на допуске (privacy by default): содержимое
// LLM-обменов попадает в логи только при LOG_LLM_BODIES=1 И LOG_LEVEL=debug.
ctx = obs.WithReqInfo(ctx, obs.ReqInfo{
TraceID: obs.NewTraceID(),
LogBodies: os.Getenv("LOG_LLM_BODIES") == "1",
})
switch inv.cmd {
case "translate":
return translate(ctx, inv.cfgPath, inv.resnapshot)
case "report":
return report(inv.cfgPath)
case "status":
return status(ctx, inv.cfgPath, inv.asJSON)
case "redrive":
return redrive(ctx, inv.cfgPath, inv.resnapshot, inv.sel)
default:
return fmt.Errorf("unknown command %q (want translate|report|status|redrive)", inv.cmd)
}
}
func translate(ctx context.Context, cfgPath string, resnapshot bool) error {
r, err := pipeline.NewRunner(cfgPath, obs.NewLogger())
if err != nil {
return err
}
defer r.Close()
r.Resnapshot = resnapshot
res, err := r.TranslateBook(ctx)
if err != nil {
return err
}
return renderTranslate(os.Stdout, res, func() (float64, float64, error) {
return r.Store.SpentUSD(res.BookID)
})
}
func report(cfgPath string) error {
// Read-only ($0): no API keys required (D20.4 — a store audit must not demand provider keys).
r, err := pipeline.NewReadOnlyRunner(cfgPath, obs.NewLogger())
if err != nil {
return err
}
defer r.Close()
// Чтения — коллбеками (interleaved с печатью, как исторически): провал чтения
// посреди аудита оставляет уже напечатанную часть на stdout (см. renderReport).
return renderReport(os.Stdout,
func() ([]store.RequestLogView, error) { return r.Store.RequestLogRows(r.Book.BookID) },
func() ([]store.ChunkStatus, error) { return r.Store.ChunkStatusesForBook(r.Book.BookID) },
func() ([]store.RetrievalState, error) { return r.Store.RetrievalStatesForBook(r.Book.BookID) },
func() (float64, float64, error) { return r.Store.SpentUSD(r.Book.BookID) })
}
// status prints the READ-ONLY progress projection (D15.3): 0 LLM, 0 replay. --json emits the
// StatusReport with stable disposition/flag_reason enums for CI/IDE; the default is a human
// dashboard (unit counts, per-chapter quality passports, money, secondary ETA). BOTH modes exit 2
// (completed-with-flags) when flagged>0 (minor 1d — --json used to always exit 0), so a consumer
// never reads a clean 0 over a book that still needs attention.
func status(ctx context.Context, cfgPath string, asJSON bool) error {
// Read-only ($0): no API keys required (D20.4 — a progress projection must not demand keys).
r, err := pipeline.NewReadOnlyRunner(cfgPath, obs.NewLogger())
if err != nil {
return err
}
defer r.Close()
rep, err := r.Status(ctx)
if err != nil {
return err
}
if asJSON {
return renderStatusJSON(os.Stdout, rep)
}
return renderStatusHuman(os.Stdout, rep, cfgPath)
}
// redrive re-attacks the FLAGGED chunks matching the selector (D15.3): it resets their terminal
// flag (chunk_status + checkpoints of the flagged/skipped stages) and re-runs the durable loop
// with a FRESH retry/escalation budget, never touching DispOK work. On --dry-run it only reports
// the plan. Redrive-calls bill normally; money already spent on the discarded attempts stays
// committed (honest). By default it requires the current config to render the SAME snapshot the
// flagged rows carry (else it fails loud); passing --resnapshot wires r.Resnapshot so Redrive skips
// that drift guard and accepts the re-pin/re-pay explicitly (D20.4: the flag used to be parsed but
// silently ignored — Redrive already honoured r.Resnapshot, only the CLI never set it).
func redrive(ctx context.Context, cfgPath string, resnapshot bool, sel pipeline.RedriveSelector) error {
r, err := pipeline.NewRunner(cfgPath, obs.NewLogger())
if err != nil {
return err
}
defer r.Close()
r.Resnapshot = resnapshot
summary, res, err := r.Redrive(ctx, sel)
if err != nil {
return err
}
return renderRedrive(os.Stdout, r.Book.BookID, summary, res, func() (float64, float64, error) {
return r.Store.SpentUSD(r.Book.BookID)
})
}