feat(cmd): wire auth/run dispatcher + demo docs
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
- **Trunk-Based Development.** Commit directly to `main`, one logical change per commit, every
|
||||||
commit deployable (see ADR-009). CI is the quality gate.
|
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
|
## Conventions
|
||||||
|
|
||||||
Reuses homelab conventions: Go, Dex for identity, ESO + 1Password for secrets, Postgres for
|
Reuses homelab conventions: Go, Dex for identity, ESO + 1Password for secrets, Postgres for
|
||||||
|
|||||||
+123
-5
@@ -1,10 +1,128 @@
|
|||||||
// Command tapir is the service entrypoint. Scaffold: it identifies itself so
|
// Command tapir is the service entrypoint. It dispatches the Stage-0 demo
|
||||||
// the CI smoke test has something to grep for, and exits. Wiring the HTTP
|
// subcommands:
|
||||||
// server, watcher, adapters, and config is part of the build.
|
//
|
||||||
|
// 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
|
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() {
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
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`,
|
- **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.
|
`iguana/devstral`, `iguana/deepseek-r1-14b`. **Not** the `ollama/` prefix form.
|
||||||
- **Which alias for summarization:** NOT yet decided. `confirm`. Tapir summarizes transcript
|
- **Which alias for summarization:** alias-as-config, **`confirm` resolved** (2026-06-02, Worker F).
|
||||||
text, so a capable general/instruct model on koala or iguana is the candidate — pick during the
|
The alias is never hardcoded: it is `TAPIR_SUMMARIZER_MODEL` (format `host/name`), wired through
|
||||||
build and record the choice (an ADR if it's load-bearing). Do not assume a coder alias is right
|
the summarizer's `Endpoint.Model`. **Default: `koala/phi4-mini`** — a non-thinking instruct model
|
||||||
for prose summarization. The summarizer adapter does **not** hardcode an alias: it is config,
|
chosen for safety: it cannot fall into the empty-content trap below, so the demo summarizes even
|
||||||
env `TAPIR_SUMMARIZER_MODEL` (format `host/name`, e.g. `iguana/deepseek-r1-14b`).
|
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
|
- **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
|
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
|
parser treats an empty summary as an error for exactly this reason. **Done (2026-06-02, Worker F):**
|
||||||
thinking model, add a generous `max_tokens` to the copied `llm.Client` request (it currently
|
the copied `llm.Client` now sends a generous `max_tokens` (8192) on every request per ADR-004, so
|
||||||
sends none — change Tapir's copy per ADR-004), or pick a non-thinking instruct model.
|
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,
|
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.
|
`Router.Primary` points at this gateway with a chosen alias, `Router.Fallback` is the user's BYO.
|
||||||
|
|||||||
@@ -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
|
// on re-authorization; without them a repeat consent yields only an access
|
||||||
// token and Exchange would reject it.
|
// token and Exchange would reject it.
|
||||||
authURL := conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce)
|
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 {
|
select {
|
||||||
case <-ctx.Done():
|
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 {
|
if err := Exchange(ctx, c, secrets, res.code); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user