From d21077303d0ef36a862fa428d2e740abdce92561 Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 11 Jun 2026 18:38:18 +0200 Subject: [PATCH] feat(config): add OnboardSummarizerModel + OnboardMaxVideoSeconds (ADR-028) Two knobs for the onboarding-burst quality work: - TAPIR_ONBOARD_SUMMARIZER_MODEL (default iguana/gemma4-26b): the stronger model the burst leads its chain with; empty collapses the burst onto the shared processor (lookupOr, so explicit-empty disables). - TAPIR_ONBOARD_MAX_VIDEO_SECONDS (default 14400/4h): upper duration bound for burst picks; 0 disables, negative clamps to 0. Table-driven tests cover defaults, explicit, disable, and invalid input. --- internal/config/config.go | 107 +++++++++++++++++++++------------ internal/config/config_test.go | 59 ++++++++++++++++++ 2 files changed, 126 insertions(+), 40 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 4ffa0e4..ed01146 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -120,6 +120,21 @@ type Config struct { // caption rate gate (ADR-014) — the cap bounds count, never the pacing. Default 3. OnboardSummarizeCount int + // OnboardSummarizerModel is the summarizer alias the connect-time burst leads + // its chain with (ADR-028) — a stronger model is affordable on the ≤3 summaries + // that form a new user's first impression. It heads a burst-specific chain; + // the standard chain (ADR-022) follows as resilience. Empty (or equal to + // SummarizerModel) collapses the burst back onto the shared processor — the + // reversibility lever. Default iguana/gemma4-26b (the brain-validated model). + OnboardSummarizerModel string + + // OnboardMaxVideoSeconds upper-bounds the duration of a video the onboarding + // burst will pick (ADR-028), so the burst does not spend a scarce caption fetch + // on a multi-hour livestream VOD that passed the live filter once it ended. Only + // a KNOWN duration outside [MinVideoSeconds, this] is dropped; a NULL/unknown + // duration is kept (degrade-open). 0 disables the upper bound. Default 14400 (4h). + OnboardMaxVideoSeconds int + // DiscoveryInterval, when > 0, makes `serve` run in-process scheduled discovery // for ALL users on that cadence (ADR-018). Zero/unset = disabled, so dev and // tests never auto-fetch. Single-replica assumption — see cmdServe. @@ -149,26 +164,28 @@ func (c Config) DexConfigured() bool { return strings.TrimSpace(c.OIDCIssuer) != // Defaults (see docs/homelab-integration.md). All overridable via env. const ( - defaultGatewayURL = "http://koala:30401/v1" - defaultSummarizerModel = "koala/phi4-mini" - defaultFallbackModel = "iguana/gemma4-26b" - defaultCloudFallbackModel = "berget/mistral-small" - defaultSummaryMaxTokens = 1500 - defaultMaxTranscriptChars = 18000 - defaultMinVideoSeconds = 60 - defaultCaptionlessThreshold = 5 - defaultCaptionlessWindow = 14 * 24 * time.Hour - defaultSummarizerTimeout = 5 * time.Minute - defaultYTTokenRef = "youtube/refresh_token" - defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback" - defaultOAuthRedirectAddr = "localhost:8080" - defaultHTTPAddr = ":8080" - defaultFetchBackoff = time.Hour - defaultFetchRate = 2 * time.Second - defaultPublicURL = "https://tapir.d-ma.be" - defaultAutoSummarizeWindow = 7 * 24 * time.Hour - defaultOnboardSummarizeCount = 3 - maxOnboardSummarizeCount = 5 + defaultGatewayURL = "http://koala:30401/v1" + defaultSummarizerModel = "koala/phi4-mini" + defaultFallbackModel = "iguana/gemma4-26b" + defaultCloudFallbackModel = "berget/mistral-small" + defaultSummaryMaxTokens = 1500 + defaultMaxTranscriptChars = 18000 + defaultMinVideoSeconds = 60 + defaultCaptionlessThreshold = 5 + defaultCaptionlessWindow = 14 * 24 * time.Hour + defaultSummarizerTimeout = 5 * time.Minute + defaultYTTokenRef = "youtube/refresh_token" + defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback" + defaultOAuthRedirectAddr = "localhost:8080" + defaultHTTPAddr = ":8080" + defaultFetchBackoff = time.Hour + defaultFetchRate = 2 * time.Second + defaultPublicURL = "https://tapir.d-ma.be" + defaultAutoSummarizeWindow = 7 * 24 * time.Hour + defaultOnboardSummarizeCount = 3 + maxOnboardSummarizeCount = 5 + defaultOnboardSummarizerModel = "iguana/gemma4-26b" + defaultOnboardMaxVideoSeconds = 14400 // 4h ) // Load reads the environment into a Config, applying defaults. It does not @@ -177,26 +194,27 @@ const ( // it needs. func Load() (Config, error) { c := Config{ - UserID: os.Getenv("TAPIR_USER_ID"), - GatewayURL: envOr("TAPIR_GATEWAY_URL", defaultGatewayURL), - GatewayKey: os.Getenv("TAPIR_GATEWAY_KEY"), - SummarizerModel: envOr("TAPIR_SUMMARIZER_MODEL", defaultSummarizerModel), - FallbackModel: lookupOr("TAPIR_FALLBACK_MODEL", defaultFallbackModel), - CloudFallbackModel: lookupOr("TAPIR_CLOUD_FALLBACK_MODEL", defaultCloudFallbackModel), - DBDSN: os.Getenv("TAPIR_DB_DSN"), - YTClientID: os.Getenv("TAPIR_YT_CLIENT_ID"), - YTClientSecret: os.Getenv("TAPIR_YT_CLIENT_SECRET"), - YTTokenRef: envOr("TAPIR_YT_TOKEN_REF", defaultYTTokenRef), - YTConnectRedirectURL: envOr("TAPIR_YT_CONNECT_REDIRECT_URL", defaultYTConnectRedirectURL), - SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()), - OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr), - HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr), - PublicURL: envOr("TAPIR_PUBLIC_URL", defaultPublicURL), - OIDCIssuer: os.Getenv("TAPIR_OIDC_ISSUER"), - DexClientID: os.Getenv("TAPIR_DEX_CLIENT_ID"), - DexClientSecret: os.Getenv("TAPIR_DEX_CLIENT_SECRET"), - OIDCRedirectURL: os.Getenv("TAPIR_OIDC_REDIRECT_URL"), - SessionSecret: os.Getenv("TAPIR_SESSION_SECRET"), + UserID: os.Getenv("TAPIR_USER_ID"), + GatewayURL: envOr("TAPIR_GATEWAY_URL", defaultGatewayURL), + GatewayKey: os.Getenv("TAPIR_GATEWAY_KEY"), + SummarizerModel: envOr("TAPIR_SUMMARIZER_MODEL", defaultSummarizerModel), + OnboardSummarizerModel: lookupOr("TAPIR_ONBOARD_SUMMARIZER_MODEL", defaultOnboardSummarizerModel), + FallbackModel: lookupOr("TAPIR_FALLBACK_MODEL", defaultFallbackModel), + CloudFallbackModel: lookupOr("TAPIR_CLOUD_FALLBACK_MODEL", defaultCloudFallbackModel), + DBDSN: os.Getenv("TAPIR_DB_DSN"), + YTClientID: os.Getenv("TAPIR_YT_CLIENT_ID"), + YTClientSecret: os.Getenv("TAPIR_YT_CLIENT_SECRET"), + YTTokenRef: envOr("TAPIR_YT_TOKEN_REF", defaultYTTokenRef), + YTConnectRedirectURL: envOr("TAPIR_YT_CONNECT_REDIRECT_URL", defaultYTConnectRedirectURL), + SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()), + OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr), + HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr), + PublicURL: envOr("TAPIR_PUBLIC_URL", defaultPublicURL), + OIDCIssuer: os.Getenv("TAPIR_OIDC_ISSUER"), + DexClientID: os.Getenv("TAPIR_DEX_CLIENT_ID"), + DexClientSecret: os.Getenv("TAPIR_DEX_CLIENT_SECRET"), + OIDCRedirectURL: os.Getenv("TAPIR_OIDC_REDIRECT_URL"), + SessionSecret: os.Getenv("TAPIR_SESSION_SECRET"), } timeout, err := durationOr("TAPIR_SUMMARIZER_TIMEOUT", defaultSummarizerTimeout) @@ -286,6 +304,15 @@ func Load() (Config, error) { } c.OnboardSummarizeCount = onboard + onboardMax, err := intOr("TAPIR_ONBOARD_MAX_VIDEO_SECONDS", defaultOnboardMaxVideoSeconds) + if err != nil { + return Config{}, err + } + if onboardMax < 0 { + onboardMax = 0 + } + c.OnboardMaxVideoSeconds = onboardMax + return c, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7b2c4db..8fb79d5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -214,3 +214,62 @@ func TestValidateForAuth_PassesWhenComplete(t *testing.T) { t.Errorf("ValidateForAuth: unexpected error %v", err) } } + +func TestLoad_OnboardSummarizerModel(t *testing.T) { + cases := []struct { + name, env string + set bool + want string + }{ + {"default", "", false, defaultOnboardSummarizerModel}, + {"explicit", "koala/some-model", true, "koala/some-model"}, + {"empty disables (collapses to shared processor)", "", true, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + env := map[string]string{} + if c.set { + env["TAPIR_ONBOARD_SUMMARIZER_MODEL"] = c.env + } + setEnv(t, env) + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.OnboardSummarizerModel != c.want { + t.Fatalf("OnboardSummarizerModel = %q, want %q", cfg.OnboardSummarizerModel, c.want) + } + }) + } +} + +func TestLoad_OnboardMaxVideoSeconds(t *testing.T) { + cases := []struct { + name, env string + want int + }{ + {"default", "", defaultOnboardMaxVideoSeconds}, + {"explicit", "7200", 7200}, + {"zero disables", "0", 0}, + {"negative clamps to zero", "-9", 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + setEnv(t, map[string]string{"TAPIR_ONBOARD_MAX_VIDEO_SECONDS": c.env}) + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.OnboardMaxVideoSeconds != c.want { + t.Fatalf("OnboardMaxVideoSeconds = %d, want %d", cfg.OnboardMaxVideoSeconds, c.want) + } + }) + } +} + +func TestLoad_OnboardMaxVideoSecondsInvalid(t *testing.T) { + setEnv(t, map[string]string{"TAPIR_ONBOARD_MAX_VIDEO_SECONDS": "long"}) + if _, err := Load(); err == nil { + t.Fatal("Load: want error for non-numeric TAPIR_ONBOARD_MAX_VIDEO_SECONDS") + } +}