package config import ( "fmt" "os" "regexp" "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 // TranscribeEngineTimeout bounds ONE engine call, so a wedged engine fails while the // job is still alive and the fallback (or the other engine) can take over. The job // timeout alone cannot do that (ADR-035 review). TranscribeEngineTimeout 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 defaultTranscribeEngineTimeout = 20 * 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 engineTimeout, err := durationOr("TAPIR_TRANSCRIBE_ENGINE_TIMEOUT", defaultTranscribeEngineTimeout) if err != nil { return UploadConfig{}, err } if engineTimeout <= 0 { return UploadConfig{}, fmt.Errorf("config: TAPIR_TRANSCRIBE_ENGINE_TIMEOUT must be positive, got %v", engineTimeout) } u.TranscribeEngineTimeout = engineTimeout 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 } if !IsLocalModel(u.UploadChatModel) { return UploadConfig{}, fmt.Errorf("config: TAPIR_UPLOAD_CHAT_MODEL=%q is not a local model (koala/ or iguana/): the whole private transcript is sent to it", u.UploadChatModel) } 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 } // localAlias is the shape of a model alias that runs on the homelab: a local host // prefix, then one plain name. A bare prefix check let through aliases that are shaped // like an escape (koala/../berget/x, koala//x, koala/x?host=berget): whether the // gateway would route them elsewhere is unknown, so they are refused (ADR-035 review). var localAlias = regexp.MustCompile(`^(koala|iguana)/[A-Za-z0-9][A-Za-z0-9._:-]*$`) // IsLocalModel reports whether alias is a local model. Private meeting content may be // sent to these and to nothing else: reconciliation (ADR-034) and chat over an uploaded // recording (ADR-035) are both held to it, and a cloud alias is refused at start-up // rather than discovered later. func IsLocalModel(alias string) bool { return localAlias.MatchString(alias) && !strings.Contains(alias, "..") } // 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 }