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 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 23:24:34 +02:00
co-authored by Claude Sonnet 5.5
parent f25fdaf398
commit 61fb50ce99
+62 -1
View File
@@ -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
}