package config import ( "fmt" "os" "strconv" "strings" "time" ) // UploadConfig holds the settings for audio upload and private transcription // (ADR-033). It is embedded in Config, so its fields read as c.UploadsEnabled etc. type UploadConfig struct { // UploadsEnabled mounts the /uploads routes and starts the worker. Off by // default: the runtime image must contain ffmpeg and ffprobe first (ADR-033). UploadsEnabled bool // UploadMaxBytes caps one upload. UploadMaxDuration caps one recording; 90 // minutes is the longest length MEASURED, not a ceiling that was found, so // raising it is a measurement, not an edit. UploadMaxBytes int64 UploadMaxDuration time.Duration // TranscribeModel is the primary gateway alias; TranscribeFallbackModel is the // fallback, tried on ANY primary failure. The fallback is a cloud endpoint // (berget): set it EMPTY to keep private audio on the homelab. TranscribeModel string TranscribeFallbackModel string // TranscribeTimeout bounds one job (conversion + transcription); a 90 minute // recording took ~7.5 minutes on iguana. TranscribeTimeout time.Duration // UploadTmpDir is where uploads are spooled and converted. Audio is deleted // after transcription, so this holds at most the in-flight files. UploadTmpDir string FFmpegBin string FFprobeBin string // MaxUploadedTranscriptChars bounds the transcript sent to a chat model for an // UPLOADED recording. The 18,000-char default for captions is ~22 minutes of // speech; an hour of meeting is ~50,000 chars. MEASURED 2026-10-01 against the // real models: iguana/gemma4-26b answers correctly to 90,000 chars but silently // drops the head of a 120,000-char transcript, so 90,000 is the ceiling of the // best model, not a round number. MaxUploadedTranscriptChars int // UploadChatModel is the model chat over an uploaded recording opens with. It is // NOT the summarizer default: koala/phi4-mini has an 8,192-token context and // returns HTTP 400 on an hour of speech (~13,000 tokens). UploadChatModel string // ReconcileBaseModel turns on two-engine transcription (ADR-034): the engine whose // TEXT is the base of the merged transcript (e.g. iguana/pianissimo). TranscribeModel // stays the other engine (e.g. kb-whisper). Empty = off. ReconcileBaseModel string // ReconcileModel is the LOCAL chat model that chooses between the two engines' // disagreements. Required when ReconcileBaseModel is set. ReconcileModel string // ReconcileCheckOrder asks the model twice per dispute with the reading order // swapped and keeps only agreement. It doubles the time; off by default. ReconcileCheckOrder bool } const ( defaultUploadMaxMB = 200 defaultUploadMaxMinutes = 90 defaultTranscribeModel = "iguana/kb-whisper" defaultTranscribeFallbackModel = "berget/kb-whisper" defaultTranscribeTimeout = 60 * time.Minute defaultMaxUploadedChars = 90000 defaultUploadChatModel = "iguana/gemma4-26b" ) func loadUploadConfig() (UploadConfig, error) { u := UploadConfig{ TranscribeModel: envOr("TAPIR_TRANSCRIBE_MODEL", defaultTranscribeModel), TranscribeFallbackModel: lookupOr("TAPIR_TRANSCRIBE_FALLBACK_MODEL", defaultTranscribeFallbackModel), UploadTmpDir: envOr("TAPIR_UPLOAD_TMP_DIR", os.TempDir()), FFmpegBin: envOr("TAPIR_FFMPEG_BIN", "ffmpeg"), FFprobeBin: envOr("TAPIR_FFPROBE_BIN", "ffprobe"), UploadChatModel: envOr("TAPIR_UPLOAD_CHAT_MODEL", defaultUploadChatModel), ReconcileBaseModel: os.Getenv("TAPIR_RECONCILE_BASE_MODEL"), ReconcileModel: os.Getenv("TAPIR_RECONCILE_MODEL"), } if v := os.Getenv("TAPIR_UPLOADS_ENABLED"); v != "" { on, err := strconv.ParseBool(v) if err != nil { return UploadConfig{}, fmt.Errorf("config: TAPIR_UPLOADS_ENABLED=%q: %w", v, err) } u.UploadsEnabled = on } mb, err := positiveInt("TAPIR_UPLOAD_MAX_MB", defaultUploadMaxMB) if err != nil { return UploadConfig{}, err } u.UploadMaxBytes = int64(mb) << 20 mins, err := positiveInt("TAPIR_UPLOAD_MAX_MINUTES", defaultUploadMaxMinutes) if err != nil { return UploadConfig{}, err } u.UploadMaxDuration = time.Duration(mins) * time.Minute timeout, err := durationOr("TAPIR_TRANSCRIBE_TIMEOUT", defaultTranscribeTimeout) if err != nil { return UploadConfig{}, err } if timeout <= 0 { return UploadConfig{}, fmt.Errorf("config: TAPIR_TRANSCRIBE_TIMEOUT must be positive, got %v", timeout) } u.TranscribeTimeout = timeout if v := os.Getenv("TAPIR_RECONCILE_CHECK_ORDER"); v != "" { on, err := strconv.ParseBool(v) if err != nil { return UploadConfig{}, fmt.Errorf("config: TAPIR_RECONCILE_CHECK_ORDER=%q: %w", v, err) } u.ReconcileCheckOrder = on } if err := u.validateReconcile(); err != nil { return UploadConfig{}, err } chars, err := intOr("TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS", defaultMaxUploadedChars) if err != nil { return UploadConfig{}, err } if chars < 0 { chars = 0 } u.MaxUploadedTranscriptChars = chars return u, nil } // positiveInt is intOr that refuses zero and negatives: a zero upload limit would // reject every file, which is a misconfiguration to fail on, not to run with. func positiveInt(key string, fallback int) (int, error) { n, err := intOr(key, fallback) if err != nil { return 0, err } if n <= 0 { return 0, fmt.Errorf("config: %s must be positive, got %d", key, n) } return n, nil } // localPrefixes are the host prefixes of the models that run on the homelab. Private // meeting content may be sent to these and to nothing else for reconciliation // (ADR-034): a cloud alias is refused at start-up rather than discovered later. var localPrefixes = []string{"koala/", "iguana/"} func isLocalModel(alias string) bool { for _, p := range localPrefixes { if strings.HasPrefix(alias, p) { return true } } return false } // validateReconcile checks the two-engine settings. They are inert while no base // engine is set; once one is, the setup must be complete and entirely local. func (u UploadConfig) validateReconcile() error { if u.ReconcileBaseModel == "" { return nil } if u.ReconcileModel == "" { return fmt.Errorf("config: TAPIR_RECONCILE_BASE_MODEL is set but TAPIR_RECONCILE_MODEL (the adjudicator) is not") } for _, m := range []struct{ name, alias string }{ {"TAPIR_RECONCILE_BASE_MODEL", u.ReconcileBaseModel}, {"TAPIR_RECONCILE_MODEL", u.ReconcileModel}, } { if !isLocalModel(m.alias) { return fmt.Errorf("config: %s=%q is not a local model (koala/ or iguana/): private meeting content must not leave the homelab for reconciliation", m.name, m.alias) } } if u.ReconcileBaseModel == u.TranscribeModel { return fmt.Errorf("config: TAPIR_RECONCILE_BASE_MODEL and TAPIR_TRANSCRIBE_MODEL are both %q: two engines must differ", u.TranscribeModel) } return nil }