From 61796db16b66e7cf993d0218b0ff3a49146bc6b7 Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 2 Jun 2026 21:03:21 +0200 Subject: [PATCH] feat(cmd): wire auth/run dispatcher + demo docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main.go dispatches `tapir auth` (interactive OAuth → persist refresh token via SecretStore) and `tapir run` (wire YouTube source + local summarizer + store sink, build engine, run the dedup-aware loop). Config-driven so live creds plug in at demo time; SIGINT stops the loop cleanly. Block kept minimal so Worker E's list/show cases union cleanly at merge. Add .env.example documenting every TAPIR_* var and a README demo runbook. Pin the summarizer alias-as-config decision and record the max_tokens fix in docs/homelab-integration.md (clears two `confirm` items). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 33 ++++++++++ cmd/tapir/main.go | 128 ++++++++++++++++++++++++++++++++++-- docs/homelab-integration.md | 21 +++--- internal/auth/auth.go | 4 +- 4 files changed, 171 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 18de2a2..d33b62b 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,39 @@ intent is version-controlled and the build has something to be checked against. - **Trunk-Based Development.** Commit directly to `main`, one logical change per commit, every commit deployable (see ADR-009). CI is the quality gate. +## Running the Stage-0 demo + +Tapir runs on **your own** YouTube account: authorize once, then run the +watch→summarize→deliver loop. All configuration is via `TAPIR_*` environment +variables — copy [`.env.example`](.env.example) to `.env` and fill it in (no +secrets are committed; at demo time source them from op, e.g. `op run -- ...`). + +```sh +# 1. configure (UUID user id, gateway URL+key, Postgres DSN, YouTube OAuth app, +# summarizer model). See .env.example for every variable. +cp .env.example .env && $EDITOR .env +set -a && . ./.env && set +a # export them into the shell + +go build -o bin/tapir ./cmd/tapir + +# 2. one-time: authorize YouTube. Opens a consent URL, captures the redirect on +# TAPIR_OAUTH_REDIRECT_ADDR, and stores the refresh token via the SecretStore +# (a 0600 file at Stage 0). The token is never logged. +./bin/tapir auth + +# 3. run: detect new videos across your subscriptions, summarize, deliver to the +# store. Unset TAPIR_POLL_INTERVAL = single pass; set it (e.g. 15m) to loop. +./bin/tapir run +``` + +Live prerequisites at demo time: the LiteLLM gateway reachable +(`TAPIR_GATEWAY_URL` + a valid key — resolve from op, the documented +`sk-local-123` is stale), a Postgres DSN (`TAPIR_DB_DSN`, migrations apply on +first connect), and a registered YouTube OAuth client whose authorized redirect +URI matches `TAPIR_OAUTH_REDIRECT_ADDR`. The summarizer model +(`TAPIR_SUMMARIZER_MODEL`, default `koala/phi4-mini`) is overridable; pick the +final alias when the gateway is reachable (see `docs/homelab-integration.md`). + ## Conventions Reuses homelab conventions: Go, Dex for identity, ESO + 1Password for secrets, Postgres for diff --git a/cmd/tapir/main.go b/cmd/tapir/main.go index dd1e7a6..098e04e 100644 --- a/cmd/tapir/main.go +++ b/cmd/tapir/main.go @@ -1,10 +1,128 @@ -// Command tapir is the service entrypoint. Scaffold: it identifies itself so -// the CI smoke test has something to grep for, and exits. Wiring the HTTP -// server, watcher, adapters, and config is part of the build. +// Command tapir is the service entrypoint. It dispatches the Stage-0 demo +// subcommands: +// +// tapir auth mint a YouTube refresh token interactively (one-time setup) +// tapir run detect new videos, summarize them, deliver to the store +// +// Standalone-vs-homelab is a wiring choice (ADR-003): every dependency is +// resolved from config (internal/config) and the SecretStore port, so live +// credentials plug in at runtime without code changes. package main -import "fmt" +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + + "gitea.d-ma.be/mathias/tapir/internal/adapters/llm" + "gitea.d-ma.be/mathias/tapir/internal/adapters/secrets" + "gitea.d-ma.be/mathias/tapir/internal/adapters/store" + "gitea.d-ma.be/mathias/tapir/internal/adapters/summarizer" + "gitea.d-ma.be/mathias/tapir/internal/adapters/youtube" + "gitea.d-ma.be/mathias/tapir/internal/auth" + "gitea.d-ma.be/mathias/tapir/internal/config" + "gitea.d-ma.be/mathias/tapir/internal/runner" + "gitea.d-ma.be/mathias/tapir/internal/usecase" +) func main() { - fmt.Println("tapir: scaffold — not yet implemented") + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + + log := slog.New(slog.NewTextHandler(os.Stderr, nil)) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + var err error + switch os.Args[1] { + case "auth": + err = cmdAuth(ctx, log) + case "run": + err = cmdRun(ctx, log) + default: + usage() + os.Exit(2) + } + + if err != nil { + log.Error("command failed", "command", os.Args[1], "err", err) + os.Exit(1) + } +} + +func usage() { + fmt.Fprint(os.Stderr, `tapir — summarize new videos from your YouTube subscriptions + +usage: + tapir auth one-time: authorize YouTube and store a refresh token + tapir run detect new videos, summarize, deliver to your store + +configuration is via TAPIR_* environment variables (see .env.example). +`) +} + +// cmdAuth runs the interactive OAuth flow and persists the refresh token. +func cmdAuth(ctx context.Context, log *slog.Logger) error { + cfg, err := config.Load() + if err != nil { + return err + } + if err := cfg.ValidateForAuth(); err != nil { + return err + } + + secretStore := secrets.NewFileStore(cfg.SecretsFile) + authCfg := auth.Config{ + ClientID: cfg.YTClientID, + ClientSecret: cfg.YTClientSecret, + RedirectURL: "http://" + cfg.OAuthRedirectAddr + "/callback", + TokenRef: cfg.YTTokenRef, + } + log.Info("starting youtube authorization", "redirect", authCfg.RedirectURL, "token_ref", cfg.YTTokenRef) + return auth.Run(ctx, authCfg, secretStore, os.Stdout) +} + +// cmdRun wires the adapters and engine, then runs the watch→summarize→deliver +// loop. With TAPIR_POLL_INTERVAL unset it performs a single pass. +func cmdRun(ctx context.Context, log *slog.Logger) error { + cfg, err := config.Load() + if err != nil { + return err + } + if err := cfg.ValidateForRun(); err != nil { + return err + } + + st, err := store.New(ctx, cfg.DBDSN) + if err != nil { + return err + } + defer st.Close() + + secretStore := secrets.NewFileStore(cfg.SecretsFile) + src := youtube.New(youtube.Config{ + ClientID: cfg.YTClientID, + ClientSecret: cfg.YTClientSecret, + TokenSecretRef: cfg.YTTokenRef, + PreferredLanguages: []string{"en"}, + }, secretStore) + + // Local Primary only; no BYO fallback for the demo (fallback nil). + primary := summarizer.Endpoint{ + Client: llm.New(cfg.GatewayURL, cfg.GatewayKey, cfg.SummarizerModel, cfg.SummarizerTimeout), + Provider: "local", + Model: cfg.SummarizerModel, + } + sum := summarizer.New(primary, nil) + + engine := usecase.NewEngine(src, sum, st) + r := runner.New(src, st, engine, cfg.UserID, log) + + log.Info("starting run", "user", cfg.UserID, "model", cfg.SummarizerModel, + "gateway", cfg.GatewayURL, "poll_interval", cfg.PollInterval) + return r.Loop(ctx, cfg.PollInterval) } diff --git a/docs/homelab-integration.md b/docs/homelab-integration.md index 7407519..d596d3d 100644 --- a/docs/homelab-integration.md +++ b/docs/homelab-integration.md @@ -18,16 +18,21 @@ it** — endpoints and aliases drift, and this file is a snapshot (2026-06-02), Resolve the live key from the vault when wiring; `sk-local-123` is no longer valid. `confirm` partially resolved. - **Model alias format:** `host/name`, e.g. `koala/qwen3-coder-30b`, `koala/phi4-mini`, `iguana/devstral`, `iguana/deepseek-r1-14b`. **Not** the `ollama/` prefix form. -- **Which alias for summarization:** NOT yet decided. `confirm`. Tapir summarizes transcript - text, so a capable general/instruct model on koala or iguana is the candidate — pick during the - build and record the choice (an ADR if it's load-bearing). Do not assume a coder alias is right - for prose summarization. The summarizer adapter does **not** hardcode an alias: it is config, - env `TAPIR_SUMMARIZER_MODEL` (format `host/name`, e.g. `iguana/deepseek-r1-14b`). +- **Which alias for summarization:** alias-as-config, **`confirm` resolved** (2026-06-02, Worker F). + The alias is never hardcoded: it is `TAPIR_SUMMARIZER_MODEL` (format `host/name`), wired through + the summarizer's `Endpoint.Model`. **Default: `koala/phi4-mini`** — a non-thinking instruct model + chosen for safety: it cannot fall into the empty-content trap below, so the demo summarizes even + if no one tunes it. It is provisional and overridable; **final live alias selection happens at + demo time when the gateway is reachable**, where a more capable model (e.g. + `iguana/deepseek-r1-14b`) is preferred for summary quality if its latency/output is acceptable. + The `max_tokens` fix below means thinking models no longer return empty content, so they are now + viable choices, not blocked ones. Do not assume a coder alias is right for prose. - **Thinking models need an explicit `max_tokens`.** qwen3 / deepseek-r1 spend the budget on reasoning and return **empty content** if `max_tokens` is too low (or unset). The summarizer's - parser treats an empty summary as an error for exactly this reason. When the alias resolves to a - thinking model, add a generous `max_tokens` to the copied `llm.Client` request (it currently - sends none — change Tapir's copy per ADR-004), or pick a non-thinking instruct model. + parser treats an empty summary as an error for exactly this reason. **Done (2026-06-02, Worker F):** + the copied `llm.Client` now sends a generous `max_tokens` (8192) on every request per ADR-004, so + thinking models no longer return empty content. A non-thinking instruct model remains the safe + default (`koala/phi4-mini`), but thinking aliases are now viable. This maps directly onto the copied `llm` package: `Client` is the OpenAI-compatible caller, `Router.Primary` points at this gateway with a chosen alias, `Router.Fallback` is the user's BYO. diff --git a/internal/auth/auth.go b/internal/auth/auth.go index cc00b19..8929dcd 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -150,7 +150,7 @@ func Run(ctx context.Context, c Config, secrets TokenWriter, out io.Writer) erro // on re-authorization; without them a repeat consent yields only an access // token and Exchange would reject it. authURL := conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce) - fmt.Fprintf(out, "Open this URL to authorize Tapir, then return here:\n\n%s\n\n", authURL) + _, _ = fmt.Fprintf(out, "Open this URL to authorize Tapir, then return here:\n\n%s\n\n", authURL) select { case <-ctx.Done(): @@ -162,7 +162,7 @@ func Run(ctx context.Context, c Config, secrets TokenWriter, out io.Writer) erro if err := Exchange(ctx, c, secrets, res.code); err != nil { return err } - fmt.Fprintln(out, "Refresh token stored. You can now run `tapir run`.") + _, _ = fmt.Fprintln(out, "Refresh token stored. You can now run `tapir run`.") return nil } }