feat(summarizer): resilient endpoint chain with local→cloud fallback (ADR-022)
The first friendly-pilot live run produced zero summaries: koala/phi4-mini hit three silent failure modes — 8k context overflow on long transcripts (HTTP 400), intermittent malformed JSON (highlights as a bare string), and no fallback wired at all (summarizer.New(primary, nil)). Keep phi4-mini as the fast primary and add resilience around it: - Ordered endpoint chain (summarizer.NewChain): phi4-mini → koala/phi4-14b (local) → berget/mistral-small (worst-case external). All reached through the one LiteLLM gateway by alias. - A parse failure now advances the chain like a transport error — the old Primary→Fallback shape returned the parse error without trying anyone else. - Tolerant parse: highlights/takeaways coerce string→[]string, absorbing the common small-model quirk without spending a fallback round-trip. - Transcript truncation (TAPIR_MAX_TRANSCRIPT_CHARS=18000) prevents the overflow rather than recovering from it; validated to fit phi4-mini's 8k window. - Bounded completion budget (TAPIR_SUMMARY_MAX_TOKENS=1500) — the old 8192 budget itself contributed to the overflow. Local-first guarantee preserved by ordering: external endpoint is tried only after every local one fails. TAPIR_CLOUD_FALLBACK_MODEL="" disables it entirely for client/NDA deployments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,8 +28,24 @@ type Config struct {
|
||||
GatewayURL string
|
||||
// GatewayKey authorizes the gateway. Read from env, never committed.
|
||||
GatewayKey string
|
||||
// SummarizerModel is the alias in host/name form, e.g. "koala/phi4-mini".
|
||||
// SummarizerModel is the primary summarizer alias in host/name form, tried
|
||||
// first on every video, e.g. "koala/phi4-mini".
|
||||
SummarizerModel string
|
||||
// FallbackModel is the LOCAL fallback alias tried when the primary fails or
|
||||
// returns unparseable output (ADR-022). Kept local so content stays on the
|
||||
// homelab stack. Empty disables it. Default a bigger-context local model.
|
||||
FallbackModel string
|
||||
// CloudFallbackModel is the worst-case EXTERNAL fallback alias, tried only
|
||||
// after every local endpoint has failed (ADR-022). For client deployments set
|
||||
// this empty so content never leaves the local stack. Default a berget alias.
|
||||
CloudFallbackModel string
|
||||
// SummaryMaxTokens caps the completion budget per summary call. Small-context
|
||||
// models (koala/phi4-mini, 8k) overflow when prompt + max_tokens exceeds the
|
||||
// window; a summary needs only a few hundred tokens, so the default is small.
|
||||
SummaryMaxTokens int
|
||||
// MaxTranscriptChars bounds the transcript text sent to the model so a long
|
||||
// transcript does not overflow a small-context primary. 0 disables truncation.
|
||||
MaxTranscriptChars int
|
||||
// SummarizerTimeout bounds a single completion call. Thinking models are
|
||||
// slow, so the default is generous.
|
||||
SummarizerTimeout time.Duration
|
||||
@@ -118,6 +134,10 @@ func (c Config) DexConfigured() bool { return strings.TrimSpace(c.OIDCIssuer) !=
|
||||
const (
|
||||
defaultGatewayURL = "http://koala:30401/v1"
|
||||
defaultSummarizerModel = "koala/phi4-mini"
|
||||
defaultFallbackModel = "koala/phi4-14b"
|
||||
defaultCloudFallbackModel = "berget/mistral-small"
|
||||
defaultSummaryMaxTokens = 1500
|
||||
defaultMaxTranscriptChars = 18000
|
||||
defaultSummarizerTimeout = 5 * time.Minute
|
||||
defaultYTTokenRef = "youtube/refresh_token"
|
||||
defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback"
|
||||
@@ -141,6 +161,8 @@ func Load() (Config, error) {
|
||||
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"),
|
||||
@@ -193,6 +215,21 @@ func Load() (Config, error) {
|
||||
}
|
||||
c.AutoSummarizeWindow = autoWindow
|
||||
|
||||
summaryTokens, err := intOr("TAPIR_SUMMARY_MAX_TOKENS", defaultSummaryMaxTokens)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
c.SummaryMaxTokens = summaryTokens
|
||||
|
||||
maxChars, err := intOr("TAPIR_MAX_TRANSCRIPT_CHARS", defaultMaxTranscriptChars)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if maxChars < 0 {
|
||||
maxChars = 0
|
||||
}
|
||||
c.MaxTranscriptChars = maxChars
|
||||
|
||||
onboard, err := intOr("TAPIR_ONBOARD_SUMMARIZE_COUNT", defaultOnboardSummarizeCount)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
@@ -269,6 +306,17 @@ func envOr(key, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// lookupOr returns the env value when the key is PRESENT (even if empty), else
|
||||
// fallback. Unlike envOr it lets an explicit empty value override the default —
|
||||
// needed to DISABLE an optional fallback model (e.g. set the cloud fallback empty
|
||||
// for a client deployment so content never leaves the local stack).
|
||||
func lookupOr(key, fallback string) string {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func intOr(key string, fallback int) (int, error) {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// unset removes an env key for the duration of the test, restoring it after.
|
||||
// Needed to observe a default for a key read with LookupEnv (where present-empty
|
||||
// means "explicitly disabled", not "use default").
|
||||
func unset(t *testing.T, key string) {
|
||||
t.Helper()
|
||||
if old, ok := os.LookupEnv(key); ok {
|
||||
t.Cleanup(func() {
|
||||
if err := os.Setenv(key, old); err != nil {
|
||||
t.Fatalf("restore %s: %v", key, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := os.Unsetenv(key); err != nil {
|
||||
t.Fatalf("unset %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// setEnv sets env vars for the test and clears them afterward, so cases don't
|
||||
// leak into one another. t.Setenv handles restoration.
|
||||
func setEnv(t *testing.T, kv map[string]string) {
|
||||
@@ -53,6 +71,46 @@ func TestLoad_AppliesDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_SummarizerChainDefaults(t *testing.T) {
|
||||
setEnv(t, map[string]string{
|
||||
"TAPIR_SUMMARIZER_MODEL": "",
|
||||
"TAPIR_SUMMARY_MAX_TOKENS": "",
|
||||
"TAPIR_MAX_TRANSCRIPT_CHARS": "",
|
||||
})
|
||||
unset(t, "TAPIR_FALLBACK_MODEL")
|
||||
unset(t, "TAPIR_CLOUD_FALLBACK_MODEL")
|
||||
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if c.FallbackModel != defaultFallbackModel {
|
||||
t.Errorf("FallbackModel = %q, want %q", c.FallbackModel, defaultFallbackModel)
|
||||
}
|
||||
if c.CloudFallbackModel != defaultCloudFallbackModel {
|
||||
t.Errorf("CloudFallbackModel = %q, want %q", c.CloudFallbackModel, defaultCloudFallbackModel)
|
||||
}
|
||||
if c.SummaryMaxTokens != defaultSummaryMaxTokens {
|
||||
t.Errorf("SummaryMaxTokens = %d, want %d", c.SummaryMaxTokens, defaultSummaryMaxTokens)
|
||||
}
|
||||
if c.MaxTranscriptChars != defaultMaxTranscriptChars {
|
||||
t.Errorf("MaxTranscriptChars = %d, want %d", c.MaxTranscriptChars, defaultMaxTranscriptChars)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicitly empty cloud-fallback env disables external routing — the lever a
|
||||
// client deployment pulls so content never leaves the local stack.
|
||||
func TestLoad_EmptyCloudFallbackDisables(t *testing.T) {
|
||||
t.Setenv("TAPIR_CLOUD_FALLBACK_MODEL", "")
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if c.CloudFallbackModel != "" {
|
||||
t.Errorf("CloudFallbackModel = %q, want empty (disabled)", c.CloudFallbackModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_OnboardSummarizeCount(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, env string
|
||||
|
||||
Reference in New Issue
Block a user