159 lines
4.7 KiB
Go
159 lines
4.7 KiB
Go
// 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 <translate|report> --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")
|
|
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.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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|