// tmctl is the TextMachine CLI (Фаза 0: translate один чанк + report). package main import ( "bufio" "context" "flag" "fmt" "os" "os/signal" "path/filepath" "strings" "syscall" "textmachine/backend/internal/obs" "textmachine/backend/internal/pipeline" ) func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, "tmctl:", err) os.Exit(1) } } func run() error { if len(os.Args) < 2 { return fmt.Errorf("usage: tmctl --config book.yaml") } cmd, args := os.Args[1], os.Args[2:] fs := flag.NewFlagSet(cmd, flag.ExitOnError) cfgPath := fs.String("config", "", "path to book.yaml") 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() ctx = obs.WithReqInfo(ctx, obs.ReqInfo{TraceID: obs.NewTraceID()}) switch cmd { case "translate": return translate(ctx, *cfgPath) case "report": return report(*cfgPath) default: return fmt.Errorf("unknown command %q (want translate|report)", cmd) } } func translate(ctx context.Context, cfgPath string) error { r, err := pipeline.NewRunner(cfgPath, obs.NewLogger()) if err != nil { return err } defer r.Close() res, err := r.TranslateOneChunk(ctx) if err != nil { return err } fmt.Println("=== ПЕРЕВОД ===") fmt.Println(res.FinalText) fmt.Println() fmt.Println("=== СТОИМОСТЬ ПО СТАДИЯМ ===") for _, st := range res.Stages { src := "call" if st.FromResume { src = "checkpoint" } fmt.Printf("%-8s %-22s %-10s $%.6f in=%d (cached=%d, cache_write=%d) out=%d+%d %dms finish=%s\n", st.Stage, st.Model, src, st.CostUSD, st.Usage.PromptTokens, st.Usage.CachedTokens, st.Usage.CacheCreationTokens, st.Usage.CompletionTokens, st.Usage.ReasoningTokens, st.LatencyMS, st.FinishReason) } fmt.Printf("ИТОГО (этот запуск): $%.6f\n", res.TotalUSD) committed, reserved, err := r.Store.SpentUSD(res.BookID) if err != nil { return err } fmt.Printf("Ledger книги: committed=$%.6f reserved=$%.6f\n", committed, reserved) return nil } 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 } defer rows.Close() 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 rows.Next() { var ts, stage, role, model, finish string var prompt, cached, cwrite, compl, reason, latency, tmhit, ok int var cost float64 if err := rows.Scan(&ts, &stage, &role, &model, &prompt, &cached, &cwrite, &compl, &reason, &cost, &latency, &finish, &tmhit, &ok); err != nil { return err } fmt.Printf("%-20s %-8s %-12s %-22s %8d %8d %8d %8d %8d %10.6f %8d %-8s %-5d %-3d\n", ts, stage, role, model, prompt, cached, cwrite, compl, reason, cost, latency, finish, tmhit, ok) } if err := rows.Err(); err != nil { return err } 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.Trim(strings.TrimSpace(v), `"'`) if os.Getenv(k) == "" { os.Setenv(k, v) } } }