Files
tapir/internal/config/uploads.go
T

198 lines
8.0 KiB
Go

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
}