From 61fb50ce9910baf5eaaccf62f378ab917ea3b042 Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 1 Oct 2026 23:24:34 +0200 Subject: [PATCH] feat(config): two-engine reconciliation settings with a local-only guard (ADR-034) TAPIR_RECONCILE_BASE_MODEL turns it on and needs TAPIR_RECONCILE_MODEL; every model must be a local alias (koala/ or iguana/, matched as a PREFIX): a cloud adjudicator is a start-up error because private meeting content must not reach it; the two engines must differ; default job timeout 60m. Off by default. Mutation-checked. Co-Authored-By: Claude Sonnet 5.5 --- internal/config/uploads.go | 63 +++++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/internal/config/uploads.go b/internal/config/uploads.go index ac4942a..f428eba 100644 --- a/internal/config/uploads.go +++ b/internal/config/uploads.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strconv" + "strings" "time" ) @@ -42,6 +43,16 @@ type UploadConfig struct { // 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 ( @@ -49,7 +60,7 @@ const ( defaultUploadMaxMinutes = 90 defaultTranscribeModel = "iguana/kb-whisper" defaultTranscribeFallbackModel = "berget/kb-whisper" - defaultTranscribeTimeout = 30 * time.Minute + defaultTranscribeTimeout = 60 * time.Minute defaultMaxUploadedChars = 90000 defaultUploadChatModel = "iguana/gemma4-26b" ) @@ -62,6 +73,8 @@ func loadUploadConfig() (UploadConfig, error) { 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 != "" { @@ -93,6 +106,17 @@ func loadUploadConfig() (UploadConfig, error) { } u.TranscribeTimeout = timeout + 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 + } + chars, err := intOr("TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS", defaultMaxUploadedChars) if err != nil { return UploadConfig{}, err @@ -116,3 +140,40 @@ func positiveInt(key string, fallback int) (int, error) { } return n, nil } + +// localPrefixes are the host prefixes of the models that run on the homelab. Private +// meeting content may be sent to these and to nothing else for reconciliation +// (ADR-034): a cloud alias is refused at start-up rather than discovered later. +var localPrefixes = []string{"koala/", "iguana/"} + +func isLocalModel(alias string) bool { + for _, p := range localPrefixes { + if strings.HasPrefix(alias, p) { + return true + } + } + return false +} + +// 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 +}