167 lines
6.1 KiB
Go
167 lines
6.1 KiB
Go
// 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).
|
||
if err := 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) }); err != nil {
|
||
return err
|
||
}
|
||
// Per-run quality-report (D39 слой 5): aggregate the deterministic quality signals into a readable
|
||
// summary — the "telemetry from day one" the owner asked for (H5). Read-only ($0), never a gate.
|
||
q, err := r.QualityReport()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return renderQuality(os.Stdout, q)
|
||
}
|
||
|
||
// 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)
|
||
})
|
||
}
|