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.
This commit is contained in:
2026-06-11 18:38:18 +02:00
parent 15926fac0e
commit 679a163d3b
2 changed files with 126 additions and 40 deletions
+67 -40
View File
@@ -120,6 +120,21 @@ type Config struct {
// caption rate gate (ADR-014) — the cap bounds count, never the pacing. Default 3. // caption rate gate (ADR-014) — the cap bounds count, never the pacing. Default 3.
OnboardSummarizeCount int 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 // DiscoveryInterval, when > 0, makes `serve` run in-process scheduled discovery
// for ALL users on that cadence (ADR-018). Zero/unset = disabled, so dev and // for ALL users on that cadence (ADR-018). Zero/unset = disabled, so dev and
// tests never auto-fetch. Single-replica assumption — see cmdServe. // 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. // Defaults (see docs/homelab-integration.md). All overridable via env.
const ( const (
defaultGatewayURL = "http://koala:30401/v1" defaultGatewayURL = "http://koala:30401/v1"
defaultSummarizerModel = "koala/phi4-mini" defaultSummarizerModel = "koala/phi4-mini"
defaultFallbackModel = "iguana/gemma4-26b" defaultFallbackModel = "iguana/gemma4-26b"
defaultCloudFallbackModel = "berget/mistral-small" defaultCloudFallbackModel = "berget/mistral-small"
defaultSummaryMaxTokens = 1500 defaultSummaryMaxTokens = 1500
defaultMaxTranscriptChars = 18000 defaultMaxTranscriptChars = 18000
defaultMinVideoSeconds = 60 defaultMinVideoSeconds = 60
defaultCaptionlessThreshold = 5 defaultCaptionlessThreshold = 5
defaultCaptionlessWindow = 14 * 24 * time.Hour defaultCaptionlessWindow = 14 * 24 * time.Hour
defaultSummarizerTimeout = 5 * time.Minute defaultSummarizerTimeout = 5 * time.Minute
defaultYTTokenRef = "youtube/refresh_token" defaultYTTokenRef = "youtube/refresh_token"
defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback" defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback"
defaultOAuthRedirectAddr = "localhost:8080" defaultOAuthRedirectAddr = "localhost:8080"
defaultHTTPAddr = ":8080" defaultHTTPAddr = ":8080"
defaultFetchBackoff = time.Hour defaultFetchBackoff = time.Hour
defaultFetchRate = 2 * time.Second defaultFetchRate = 2 * time.Second
defaultPublicURL = "https://tapir.d-ma.be" defaultPublicURL = "https://tapir.d-ma.be"
defaultAutoSummarizeWindow = 7 * 24 * time.Hour defaultAutoSummarizeWindow = 7 * 24 * time.Hour
defaultOnboardSummarizeCount = 3 defaultOnboardSummarizeCount = 3
maxOnboardSummarizeCount = 5 maxOnboardSummarizeCount = 5
defaultOnboardSummarizerModel = "iguana/gemma4-26b"
defaultOnboardMaxVideoSeconds = 14400 // 4h
) )
// Load reads the environment into a Config, applying defaults. It does not // Load reads the environment into a Config, applying defaults. It does not
@@ -177,26 +194,27 @@ const (
// it needs. // it needs.
func Load() (Config, error) { func Load() (Config, error) {
c := Config{ c := Config{
UserID: os.Getenv("TAPIR_USER_ID"), UserID: os.Getenv("TAPIR_USER_ID"),
GatewayURL: envOr("TAPIR_GATEWAY_URL", defaultGatewayURL), GatewayURL: envOr("TAPIR_GATEWAY_URL", defaultGatewayURL),
GatewayKey: os.Getenv("TAPIR_GATEWAY_KEY"), GatewayKey: os.Getenv("TAPIR_GATEWAY_KEY"),
SummarizerModel: envOr("TAPIR_SUMMARIZER_MODEL", defaultSummarizerModel), SummarizerModel: envOr("TAPIR_SUMMARIZER_MODEL", defaultSummarizerModel),
FallbackModel: lookupOr("TAPIR_FALLBACK_MODEL", defaultFallbackModel), OnboardSummarizerModel: lookupOr("TAPIR_ONBOARD_SUMMARIZER_MODEL", defaultOnboardSummarizerModel),
CloudFallbackModel: lookupOr("TAPIR_CLOUD_FALLBACK_MODEL", defaultCloudFallbackModel), FallbackModel: lookupOr("TAPIR_FALLBACK_MODEL", defaultFallbackModel),
DBDSN: os.Getenv("TAPIR_DB_DSN"), CloudFallbackModel: lookupOr("TAPIR_CLOUD_FALLBACK_MODEL", defaultCloudFallbackModel),
YTClientID: os.Getenv("TAPIR_YT_CLIENT_ID"), DBDSN: os.Getenv("TAPIR_DB_DSN"),
YTClientSecret: os.Getenv("TAPIR_YT_CLIENT_SECRET"), YTClientID: os.Getenv("TAPIR_YT_CLIENT_ID"),
YTTokenRef: envOr("TAPIR_YT_TOKEN_REF", defaultYTTokenRef), YTClientSecret: os.Getenv("TAPIR_YT_CLIENT_SECRET"),
YTConnectRedirectURL: envOr("TAPIR_YT_CONNECT_REDIRECT_URL", defaultYTConnectRedirectURL), YTTokenRef: envOr("TAPIR_YT_TOKEN_REF", defaultYTTokenRef),
SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()), YTConnectRedirectURL: envOr("TAPIR_YT_CONNECT_REDIRECT_URL", defaultYTConnectRedirectURL),
OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr), SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()),
HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr), OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr),
PublicURL: envOr("TAPIR_PUBLIC_URL", defaultPublicURL), HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr),
OIDCIssuer: os.Getenv("TAPIR_OIDC_ISSUER"), PublicURL: envOr("TAPIR_PUBLIC_URL", defaultPublicURL),
DexClientID: os.Getenv("TAPIR_DEX_CLIENT_ID"), OIDCIssuer: os.Getenv("TAPIR_OIDC_ISSUER"),
DexClientSecret: os.Getenv("TAPIR_DEX_CLIENT_SECRET"), DexClientID: os.Getenv("TAPIR_DEX_CLIENT_ID"),
OIDCRedirectURL: os.Getenv("TAPIR_OIDC_REDIRECT_URL"), DexClientSecret: os.Getenv("TAPIR_DEX_CLIENT_SECRET"),
SessionSecret: os.Getenv("TAPIR_SESSION_SECRET"), OIDCRedirectURL: os.Getenv("TAPIR_OIDC_REDIRECT_URL"),
SessionSecret: os.Getenv("TAPIR_SESSION_SECRET"),
} }
timeout, err := durationOr("TAPIR_SUMMARIZER_TIMEOUT", defaultSummarizerTimeout) timeout, err := durationOr("TAPIR_SUMMARIZER_TIMEOUT", defaultSummarizerTimeout)
@@ -286,6 +304,15 @@ func Load() (Config, error) {
} }
c.OnboardSummarizeCount = onboard 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 return c, nil
} }
+59
View File
@@ -214,3 +214,62 @@ func TestValidateForAuth_PassesWhenComplete(t *testing.T) {
t.Errorf("ValidateForAuth: unexpected error %v", err) 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")
}
}