diff --git a/internal/config/config.go b/internal/config/config.go index 01f01f5..9b43d10 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,9 @@ import ( // Config is Tapir's fully-resolved runtime configuration. type Config struct { + // UploadConfig is the audio-upload and private-transcription settings (ADR-033). + UploadConfig + // UserID is the Tapir user the run operates as. Stage 0 has exactly one. // It must be a UUID: it keys the UUID user_id/video_id columns (data-model). UserID string @@ -320,6 +323,10 @@ func Load() (Config, error) { } c.OnboardMaxVideoSeconds = onboardMax + if c.UploadConfig, err = loadUploadConfig(); err != nil { + return Config{}, err + } + return c, nil } diff --git a/internal/config/uploads.go b/internal/config/uploads.go new file mode 100644 index 0000000..a496b20 --- /dev/null +++ b/internal/config/uploads.go @@ -0,0 +1,110 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "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. A guess until measured against + // the models chat offers. + MaxUploadedTranscriptChars int +} + +const ( + defaultUploadMaxMB = 200 + defaultUploadMaxMinutes = 90 + defaultTranscribeModel = "iguana/kb-whisper" + defaultTranscribeFallbackModel = "berget/kb-whisper" + defaultTranscribeTimeout = 30 * time.Minute + defaultMaxUploadedChars = 120000 +) + +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"), + } + + 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 + + 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 +}