textmachine/backend/cmd/tmctl/main.go

201 lines
7.5 KiB
Go
Raw Permalink 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 "export":
return export(inv.cfgPath, inv.asPlaintext, inv.asPairs)
case "redrive":
return redrive(ctx, inv.cfgPath, inv.resnapshot, inv.sel)
case "seed-lint":
return seedLint(inv.seedPath)
default:
return fmt.Errorf("unknown command %q (want translate|report|status|export|redrive|seed-lint)", 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)
})
}
// seedLint validates a glossary seed YAML (a manual seed or the emitted mined delta) through the REAL
// loadGlossarySeed fail-louds + the shared-key collision check (WS3). $0, no store, no provider keys — a
// pre-flight the operator runs on the mined delta before the W1.5 reseed. Prints "OK" on a clean seed.
func seedLint(seedPath string) error {
if err := pipeline.SeedLint(seedPath); err != nil {
return err
}
fmt.Fprintf(os.Stdout, "seed-lint OK: %s is loadable (0 fail-louds, 0 shared-key collisions)\n", seedPath)
return nil
}
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)
}
// export prints the READ-ONLY export projection (D39 слой 6 / D39.2-open №1): every chunk's FINAL
// export text EXACTLY by prod semantics (final_hash → checkpoint → exportNormalize), so the polygon
// extractor / reader-samples read the SAME bytes the backend ships instead of the raw checkpoint. Like
// report/status it is $0 and needs no provider keys (D20.4 — an audit surface must not demand keys).
// Default output is stable JSON (the machine surface); --plaintext emits the human concatenation.
func export(cfgPath string, asPlaintext, asPairs bool) error {
r, err := pipeline.NewReadOnlyRunner(cfgPath, obs.NewLogger())
if err != nil {
return err
}
defer r.Close()
exp, err := r.Export(asPairs)
if err != nil {
return err
}
return renderExport(os.Stdout, exp, asPlaintext)
}
// 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)
})
}