Upload a recording (audio, or video with the camera off), get a private Swedish transcript, and chat about it. - ADR-033: RLS-scoped private transcript store; ffprobe/ffmpeg intake; gateway transcription (iguana kb-whisper, berget fallback); in-process single-replica worker; chat over the transcript (local models only). - ADR-034: optional two-engine mode (pianissimo + kb-whisper) reconciled by a LOCAL model; off unless TAPIR_RECONCILE_BASE_MODEL is set. - ADR-035: real progress (phases, running/quiet/lost), phase logging, every judged spot stored, merged segments. - Hardening from four adversarial reviews: errors logged and stored as kinds never text; per-engine deadlines; converted-length check; bounded queue; client hang-ups are not errors; strict local-alias check. - Dockerfile: static ffmpeg/ffprobe 7.0.2 pinned by sha256 (#34). Built and inspected by hand; CI does not build the image on a PR, so its first CI build is this merge. Uploads stay OFF until TAPIR_UPLOADS_ENABLED is set in the deployment. Closes #33, #34, #42, #43, #44. Co-authored-by: Mathias <mthbqv@gmail.com>
198 lines
8.0 KiB
Go
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
|
|
}
|