feat(config): upload and transcription settings (ADR-033)
Embedded UploadConfig. Off by default until the image carries ffmpeg; 200 MB / 90 min; iguana primary with a berget fallback that an explicit empty TAPIR_TRANSCRIBE_FALLBACK_MODEL disables (private audio then never leaves the homelab); zero or negative limits fail at load instead of rejecting every upload at runtime. uploads.go and the two-line hook in config.go are one commit: the tests need both. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,9 @@ import (
|
|||||||
|
|
||||||
// Config is Tapir's fully-resolved runtime configuration.
|
// Config is Tapir's fully-resolved runtime configuration.
|
||||||
type Config struct {
|
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.
|
// 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).
|
// It must be a UUID: it keys the UUID user_id/video_id columns (data-model).
|
||||||
UserID string
|
UserID string
|
||||||
@@ -320,6 +323,10 @@ func Load() (Config, error) {
|
|||||||
}
|
}
|
||||||
c.OnboardMaxVideoSeconds = onboardMax
|
c.OnboardMaxVideoSeconds = onboardMax
|
||||||
|
|
||||||
|
if c.UploadConfig, err = loadUploadConfig(); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user