feat: upload audio, get a private Swedish transcript, discuss it (#33, ADR-033) #39
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,6 +43,16 @@ type UploadConfig struct {
|
|||||||
// NOT the summarizer default: koala/phi4-mini has an 8,192-token context and
|
// 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).
|
// returns HTTP 400 on an hour of speech (~13,000 tokens).
|
||||||
UploadChatModel string
|
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 (
|
const (
|
||||||
@@ -49,7 +60,7 @@ const (
|
|||||||
defaultUploadMaxMinutes = 90
|
defaultUploadMaxMinutes = 90
|
||||||
defaultTranscribeModel = "iguana/kb-whisper"
|
defaultTranscribeModel = "iguana/kb-whisper"
|
||||||
defaultTranscribeFallbackModel = "berget/kb-whisper"
|
defaultTranscribeFallbackModel = "berget/kb-whisper"
|
||||||
defaultTranscribeTimeout = 30 * time.Minute
|
defaultTranscribeTimeout = 60 * time.Minute
|
||||||
defaultMaxUploadedChars = 90000
|
defaultMaxUploadedChars = 90000
|
||||||
defaultUploadChatModel = "iguana/gemma4-26b"
|
defaultUploadChatModel = "iguana/gemma4-26b"
|
||||||
)
|
)
|
||||||
@@ -62,6 +73,8 @@ func loadUploadConfig() (UploadConfig, error) {
|
|||||||
FFmpegBin: envOr("TAPIR_FFMPEG_BIN", "ffmpeg"),
|
FFmpegBin: envOr("TAPIR_FFMPEG_BIN", "ffmpeg"),
|
||||||
FFprobeBin: envOr("TAPIR_FFPROBE_BIN", "ffprobe"),
|
FFprobeBin: envOr("TAPIR_FFPROBE_BIN", "ffprobe"),
|
||||||
UploadChatModel: envOr("TAPIR_UPLOAD_CHAT_MODEL", defaultUploadChatModel),
|
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 != "" {
|
if v := os.Getenv("TAPIR_UPLOADS_ENABLED"); v != "" {
|
||||||
@@ -93,6 +106,17 @@ func loadUploadConfig() (UploadConfig, error) {
|
|||||||
}
|
}
|
||||||
u.TranscribeTimeout = timeout
|
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)
|
chars, err := intOr("TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS", defaultMaxUploadedChars)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return UploadConfig{}, err
|
return UploadConfig{}, err
|
||||||
@@ -116,3 +140,40 @@ func positiveInt(key string, fallback int) (int, error) {
|
|||||||
}
|
}
|
||||||
return n, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user