Merge remote-tracking branch 'origin/master' into task/486-deploy-the-workstation-transcriber

This commit is contained in:
2026-08-09 01:22:03 +04:00
27 changed files with 1411 additions and 32 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ func TestChatAnswersWithNoLlamaServer(t *testing.T) {
dead := llm.New("http://127.0.0.1:1", 500*time.Millisecond)
emb := router.NewHashEmbedder(1024)
h.recall.embedder = emb
h.router = buildRouter(emb, h.matcher, 0.55, pickLLMRouter(true, dead))
h.router = buildRouter(emb, h.matcher, 0.55, pickLLMRouter(true, dead), nil)
h.replier = newLLMReplier(dead, nil)
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
+2 -2
View File
@@ -317,7 +317,7 @@ func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
h, _, now := newClarifyHandler(t)
emb := router.NewHashEmbedder(1024)
h.recall.embedder = emb
h.router = buildRouter(emb, h.matcher, 0.55, nil)
h.router = buildRouter(emb, h.matcher, 0.55, nil, nil)
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
t.Fatal("expected a question")
@@ -671,7 +671,7 @@ func TestUnresolvedActSaysItDoesNotKnowTheCommand(t *testing.T) {
func newRoutingClarifyHandler(t *testing.T) (*reactiveHandler, *store.Store) {
t.Helper()
h, st, _ := newClarifyHandler(t)
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil)
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil, nil)
h.recall = recallWiring{embedder: router.NewHashEmbedder(1024), memStore: memory.NewInMemoryStore()}
return h, st
}
+1 -1
View File
@@ -25,7 +25,7 @@ func traceHandler(t *testing.T, ring *decision.Ring) *reactiveHandler {
return &reactiveHandler{
api: api,
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil, nil),
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
dataStore: st,
+1 -1
View File
@@ -171,7 +171,7 @@ func newDialogueHandler(t *testing.T) (*reactiveHandler, *store.Store, *time.Tim
// and never a coincidence (V-577, V-579). checkEnd refuses any reminder
// landing on it, and at 09:00 the row that answers "на 9" would trip that.
*now = time.Date(2026, 7, 31, 9, 17, 0, 0, time.UTC)
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil)
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil, nil)
h.recall = recallWiring{embedder: router.NewHashEmbedder(1024), memStore: memory.NewInMemoryStore()}
return h, st, now
}
+1 -1
View File
@@ -24,7 +24,7 @@ func TestApplyAction_FactCapture_QueuesEntityResolution(t *testing.T) {
emb := router.NewHashEmbedder(1024)
matcher := tool.NewMatcher(api)
rtr := buildRouter(emb, matcher, 0.55, nil)
rtr := buildRouter(emb, matcher, 0.55, nil, nil)
h := &reactiveHandler{
api: api,
+1 -1
View File
@@ -20,7 +20,7 @@ func newFactGateHandler(t *testing.T, now time.Time) (*reactiveHandler, ipc.Core
h := &reactiveHandler{
api: api,
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil, nil),
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
dataStore: st,
+1 -1
View File
@@ -45,7 +45,7 @@ func newNoteHandler(t *testing.T) (*reactiveHandler, *store.Store) {
h := &reactiveHandler{
api: api,
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil, nil),
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
dataStore: st,
+2 -2
View File
@@ -22,7 +22,7 @@ func TestReactiveNotesReminders(t *testing.T) {
emb := router.NewHashEmbedder(1024)
matcher := tool.NewMatcher(api)
rtr := buildRouter(emb, matcher, 0.55, nil)
rtr := buildRouter(emb, matcher, 0.55, nil, nil)
h := &reactiveHandler{
api: api,
@@ -104,7 +104,7 @@ func TestSpokenTaskCaptureFilesATask(t *testing.T) {
h := &reactiveHandler{
api: api,
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
router: buildRouter(emb, matcher, 0.55, nil),
router: buildRouter(emb, matcher, 0.55, nil, nil),
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
dataStore: st,
+1 -1
View File
@@ -474,7 +474,7 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
// used to be built on a nil API, which meant any scenario that produced an
// act panicked the moment the matcher was consulted.
matcher := tool.NewMatcher(api)
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted))
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted), nil)
w.handler = &reactiveHandler{
stt: simTranscriber{},
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"testing"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/stt"
)
// A box with no workstation.stt block transcribes exactly as it did before the
// seam existed: the floor is handed back untouched, and nothing probes.
func TestSttSeamWithNoBlockIsTheFloor(t *testing.T) {
floor := stt.NewStub()
got, pair := sttSeam(&config.Config{}, floor)
if pair != nil {
t.Fatal("no block must build no pair")
}
if got != stt.Transcriber(floor) {
t.Fatal("no block must hand back the floor itself")
}
}
func TestSttSeamPrefersTheWorkstation(t *testing.T) {
cfg := &config.Config{Workstation: &config.WorkstationConfig{
URL: "http://127.0.0.1:1",
Stt: &config.WorkstationSttConfig{
URL: "http://127.0.0.1:2/transcribe",
Health: "http://127.0.0.1:2/health",
},
}}
got, pair := sttSeam(cfg, stt.NewStub())
if pair == nil {
t.Fatal("a configured block must build a pair")
}
defer pair.Stop()
if got != stt.Transcriber(pair) {
t.Fatal("the pair is what callers must transcribe through")
}
// Nothing answers on port 2, so the seam is the floor until it does.
if pair.Available() {
t.Fatal("an unreachable workstation must not be available")
}
}
+76 -4
View File
@@ -36,7 +36,9 @@ type voiceWiring struct {
sessions *voice.Sessions
voiceSink delivery.Sink
embedder router.Embedder
handler *reactiveHandler // the reactive handler for IPC Chat
// heads — the routing heads, nil unless embedder.heads_path is set.
heads *router.RouterHeads
handler *reactiveHandler // the reactive handler for IPC Chat
// worker clients (set when configured as Remote): closed on shutdown so
// mavsttd / mavttsd don't keep a stale conn into a restarting daemon.
sttClient *worker.Client
@@ -53,7 +55,11 @@ type voiceWiring struct {
// unless a `workstation` block names an address. Held here only so the
// prober is stopped on shutdown; callers were handed it at build time.
pair *llm.Pair
mcp *mcpWiring
// sttPair — CrisperWhisper 2.0 on the workstation with mavsttd as the
// floor, nil unless the `workstation.stt` block names an address. Held for
// the same reason as pair: to stop its prober on shutdown.
sttPair *stt.Pair
mcp *mcpWiring
// home — the Home Assistant client, nil unless the `smarthome` block is
// enabled (Vikunja #256). Its devices land in the same allowlist as every
// other act, so nothing else here has to know about it.
@@ -72,6 +78,9 @@ func (w *voiceWiring) close() {
if w.embedder != nil {
_ = w.embedder.Close()
}
if w.heads != nil {
_ = w.heads.Close()
}
if w.server != nil {
_ = w.server.Close()
}
@@ -84,6 +93,9 @@ func (w *voiceWiring) close() {
if w.pair != nil {
w.pair.Stop()
}
if w.sttPair != nil {
w.sttPair.Stop()
}
w.mcp.close()
}
@@ -112,6 +124,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
} else {
transcriber = stt.NewStub()
}
transcriber, w.sttPair = sttSeam(cfg, transcriber)
w.transcriber = transcriber
// ----- tts (Stub in-process OR Remote) -----
@@ -147,6 +160,24 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
emb = router.NewHashEmbedder(1024)
}
w.embedder = emb
// ----- router: routing heads (only when configured, and never fatal) -----
// A missing or broken weights file logs and leaves w.heads nil, which is
// byte-for-byte the cascade that shipped before V-664. Refusing to start
// over a routing accelerator would trade a working box for a better one.
if cfg.Voice.Embedder != nil && cfg.Voice.Embedder.HeadsPath != "" {
h, err := router.NewRouterHeads(
cfg.Voice.Embedder.HeadsPath,
cfg.Voice.Embedder.TokenizerPath,
)
if err != nil {
log.Printf("voice: routing heads unavailable, cascade unchanged: %v", err)
} else {
log.Printf("voice: routing heads loaded from %s", cfg.Voice.Embedder.HeadsPath)
w.heads = h
}
}
repairFactVectors(dataStore, emb)
checkStoredEmbedder(dataStore, emb)
// Retention is enforced on write, which is not enough on its own: a box that
@@ -223,7 +254,8 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
// against the classifier's 50.0%, at about 1s a turn instead of 30ms (see
// config.VoiceConfig.LLMRouter). The classifier always stays wired as the
// fallback, so a model error never breaks a turn.
rtr := buildRouter(emb, matcher, threshold, pickLLMRouter(cfg.Voice.UseLLMRouter(), hot))
rtr := buildRouter(emb, matcher, threshold,
pickLLMRouter(cfg.Voice.UseLLMRouter(), hot), w.heads)
// ----- sessions registry (shared with voicesink) -----
sessions := voice.NewSessions()
@@ -366,6 +398,44 @@ func modelSeam(cfg *config.Config, resident *llm.Client) (router.Completer, *llm
return pair, pair
}
// sttSeam builds the transcription seam the voice path and the meeting
// recorder share. It is modelSeam for audio and follows the same rule.
//
// With no `workstation.stt` block it hands back the floor untouched, which is
// today's deploy exactly. With one, it is an stt.Pair preferring CrisperWhisper
// 2.0 on workpc, which scores 10.4% WER in Russian against the floor's 27.5%
// (docs/evals/2026-08-09-crisperwhisper2-russian-wer.md).
//
// Only the silent half of the degradation rule applies here. A worse transcript
// is still a turn, so there is nothing to name a gap about and the fallback is
// never spoken. That is why stt.Pair has no TranscribeRemote.
func sttSeam(cfg *config.Config, floor stt.Transcriber) (stt.Transcriber, *stt.Pair) {
if cfg.Workstation == nil || cfg.Workstation.Stt == nil {
return floor, nil
}
s := cfg.Workstation.Stt
lang := ""
if cfg.Voice != nil {
lang = cfg.Voice.Lang
if cfg.Voice.Stt != nil && cfg.Voice.Stt.Lang != "" {
lang = cfg.Voice.Stt.Lang
}
}
pair := stt.NewPair(
stt.NewHTTPTranscriber(s.URL, s.Token, lang, time.Duration(s.Timeout)),
floor,
s.Health,
time.Duration(s.Probe),
)
pair.Start(context.Background())
if s.Token == "" {
log.Print("voice: the workstation transcriber has no token, so anything on the LAN can post audio to it")
}
log.Printf("voice: workstation transcriber at %s, probed every %s, mavsttd as the floor",
s.URL, time.Duration(s.Probe))
return pair, pair
}
func pickLLMRouter(enabled bool, c router.Completer) *router.LLMRouter {
if !enabled {
return nil
@@ -390,7 +460,8 @@ func pickLLMRouter(enabled bool, c router.Completer) *router.LLMRouter {
// intent from seedDir (models/seeds/<intent>.txt) — see seedClassifier
// below for the current intent list and file names.
// - Threshold is from voice.router_threshold config (default 0.55).
func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64, llmR *router.LLMRouter) *router.Router {
func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
llmR *router.LLMRouter, heads *router.RouterHeads) *router.Router {
cls := router.NewClassifier(emb)
seedClassifier(cls)
grammars := router.DefaultGrammars(acts)
@@ -442,6 +513,7 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
},
Threshold: threshold,
LLM: llmR,
Heads: heads,
})
}