textmachine/backend/cmd/tmctl/main.go

220 lines
7.1 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 (Фаза 0: translate один чанк + report).
package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"textmachine/backend/internal/obs"
"textmachine/backend/internal/pipeline"
)
// Exit codes (Веха 2): 0 clean · 2 completed-with-flags (приёмка допускает N
// флагов) · 1 infra failure. The exit-2 case is carried up as a typed sentinel
// (*pipeline.CompletedWithFlags) so a "clean run, attention needed" never
// masquerades as an infra crash and vice-versa.
func main() {
err := run()
var flagged *pipeline.CompletedWithFlags
switch {
case err == nil:
return
case errors.As(err, &flagged):
fmt.Fprintln(os.Stderr, "tmctl:", err)
os.Exit(2)
default:
fmt.Fprintln(os.Stderr, "tmctl:", err)
os.Exit(1)
}
}
func run() error {
if len(os.Args) < 2 {
return fmt.Errorf("usage: tmctl <translate|report> --config book.yaml")
}
cmd, args := os.Args[1], os.Args[2:]
// ContinueOnError (not ExitOnError): the stdlib's ExitOnError calls os.Exit(2)
// on a parse failure, which would COLLIDE with exit code 2 (completed-with-
// flags). Returning the error routes a bad flag to main()'s default branch →
// exit 1, keeping 2 exclusive to the flagged-chunks case (self-review Веха 2).
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
cfgPath := fs.String("config", "", "path to book.yaml")
resnapshot := fs.Bool("resnapshot", false, "re-pin existing jobs to the current config snapshot (пере-перевод оплаченных чанков — явное согласие)")
if err := fs.Parse(args); err != nil {
return err
}
if *cfgPath == "" {
return fmt.Errorf("--config book.yaml is required")
}
loadDotEnv(filepath.Join(filepath.Dir(*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 cmd {
case "translate":
return translate(ctx, *cfgPath, *resnapshot)
case "report":
return report(*cfgPath)
default:
return fmt.Errorf("unknown command %q (want translate|report)", 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
}
for _, ch := range res.Chunks {
fmt.Printf("=== ГЛАВА %d ЧАНК %d — %s%s ===\n", ch.Chapter, ch.ChunkIdx, ch.Disposition, flagSuffix(ch.FlagReason))
if ch.Disposition == pipeline.DispOK {
fmt.Println(ch.FinalText)
} else {
fmt.Printf("[ФЛАГ %s] чанк не переведён — черновик/редактура непригодны, помечен для человека\n", ch.FlagReason)
}
for _, st := range ch.Stages {
how := "call"
switch {
case st.Disposition == pipeline.DispSkipped:
how = "skipped"
case st.FromResume:
how = "resume"
}
fmt.Printf(" %-8s %-22s %-8s %-8s%s $%.6f (cum $%.6f) in=%d (cached=%d) out=%d+%d att=%d %dms finish=%s\n",
st.Stage, st.Model, how, st.Disposition, flagSuffix(st.FlagReason),
st.CostUSD, st.CumCostUSD,
st.Usage.PromptTokens, st.Usage.CachedTokens,
st.Usage.CompletionTokens, st.Usage.ReasoningTokens, st.Attempts, st.LatencyMS, st.FinishReason)
}
fmt.Println()
}
fmt.Printf("ИТОГО (этот запуск): $%.6f — чанков %d, флагов %d\n", res.TotalUSD, len(res.Chunks), res.Flagged)
committed, reserved, err := r.Store.SpentUSD(res.BookID)
if err != nil {
return err
}
fmt.Printf("Ledger книги: committed=$%.6f reserved=$%.6f\n", committed, reserved)
// Exit code 2 is a typed sentinel, not an infra error: the report above is
// already on stdout; main() maps this to a non-zero exit for the operator.
if res.Flagged > 0 {
return &pipeline.CompletedWithFlags{Flagged: res.Flagged, Total: len(res.Chunks)}
}
return nil
}
// flagSuffix renders a non-empty flag reason as "(reason)".
func flagSuffix(r pipeline.FlagReason) string {
if r == "" {
return ""
}
return "(" + string(r) + ")"
}
func report(cfgPath string) error {
r, err := pipeline.NewRunner(cfgPath, obs.NewLogger())
if err != nil {
return err
}
defer r.Close()
rows, err := r.Store.RequestLogRows(r.Book.BookID)
if err != nil {
return err
}
fmt.Printf("%-20s %-8s %-12s %-22s %8s %8s %8s %8s %8s %10s %8s %-8s %-5s %-3s\n",
"ts", "stage", "role", "model", "prompt", "cached", "cwrite", "compl", "reason", "cost_usd", "ms", "finish", "tmhit", "ok")
for _, row := range rows {
fmt.Printf("%-20s %-8s %-12s %-22s %8d %8d %8d %8d %8d %10.6f %8d %-8s %-5d %-3d\n",
row.TS, row.Stage, row.Role, row.ModelActual, row.PromptTokens, row.CachedTokens,
row.CacheCreationTokens, row.CompletionTokens, row.ReasoningTokens, row.CostUSD,
row.LatencyMS, row.FinishReason, row.TMHit, row.OK)
}
// Flag section (Веха 2): every chunk×stage whose disposition ≠ ok — the
// "флаг редактору" the plan requires (02-mvp Фаза-1 приёмка допускает N).
flags, err := r.Store.ChunkStatusesForBook(r.Book.BookID)
if err != nil {
return err
}
headerPrinted := false
for _, f := range flags {
if f.Disposition == "ok" {
continue
}
if !headerPrinted {
fmt.Printf("\n=== ФЛАГИ (disposition ≠ ok) ===\n")
fmt.Printf("%-4s %-6s %-8s %-9s %-16s %5s %10s %s\n",
"ch", "chunk", "stage", "disp", "reason", "att", "cost_usd", "detail")
headerPrinted = true
}
fmt.Printf("%-4d %-6d %-8s %-9s %-16s %5d %10.6f %s\n",
f.Chapter, f.ChunkIdx, f.Stage, f.Disposition, f.FlagReason, f.Attempts, f.CostUSD, f.Detail)
}
committed, reserved, err := r.Store.SpentUSD(r.Book.BookID)
if err != nil {
return err
}
fmt.Printf("\nLedger книги: committed=$%.6f reserved=$%.6f\n", committed, reserved)
return nil
}
// loadDotEnv reads KEY=VALUE lines into the environment without overriding
// already-set variables. Ключи — из backend/.env (gitignored); секреты никогда
// не попадают в конфиги/репо.
func loadDotEnv(path string) {
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
// Снимаем только ПАРНЫЕ обрамляющие кавычки: Trim по набору символов
// откусил бы легитимную кавычку в конце ключа (находка ревью).
if n := len(v); n >= 2 && (v[0] == '"' || v[0] == '\'') && v[n-1] == v[0] {
v = v[1 : n-1]
}
if os.Getenv(k) == "" {
os.Setenv(k, v)
}
}
}