Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ec92ee8bf | |||
| 7c77a378c1 | |||
| 672eabc134 | |||
| cc32c2c4ab | |||
| a1e97c94ac | |||
| c7f59e48f4 | |||
| 4666057066 | |||
| 7138086c3f | |||
| 83e168f326 | |||
| a4abcdefa3 | |||
| 68a3c85186 | |||
| 88c086482e | |||
| feabf9f350 | |||
| 50c6637c1b | |||
| ee9d55ca95 | |||
| a886217223 | |||
| de9884e063 | |||
| bbefda66e2 | |||
| a37c4138a1 | |||
| d6f391430f | |||
| 68b2aa9137 | |||
| f8fa0d1b44 |
@@ -308,9 +308,36 @@ the possessive agenda rules claim those cases at stage 0 and name nothing, so no
|
||||
label reaches the head. That is the same trade V-660 flagged and it wants the
|
||||
owner's call.
|
||||
|
||||
**Nothing of this runs in Go.** The weights are `heads.pt` and `out/body_heads/`
|
||||
on workpc. Reaching the daemon needs an ONNX export and a caller. The resident
|
||||
e5-small must not be replaced by the copy, because recall depends on that file.
|
||||
**The heads run in Go and route every turn, since 08-08-2026** (V-664,
|
||||
`docs/evals/2026-08-08-routing-heads-in-go.md`). This section used to say
|
||||
nothing of it ran. `RouterHeads` in `internal/router/heads.go` loads
|
||||
`router_heads.onnx` and reads intent, destination and clarify off one forward
|
||||
pass. It is stage 0b: after the grammars, **before** the resident model, and the
|
||||
classifier is still behind both. Through the cascade it scores intent **96.9%**
|
||||
and destination **75.8%** at p50 27.9ms. That beats the gemma-4-12b cascade,
|
||||
84.4% and 72.7%, at a twelfth of its 329ms. The workstation stays the better
|
||||
phraser and is no longer the better router.
|
||||
|
||||
Three rules around it. The **clarify head decides first**, before the intent
|
||||
threshold. It answers a different question. A thin utterance scores low
|
||||
intent by construction, so gating it cost 6 of 8 ambiguous cases. The
|
||||
**destination head is read on `IntentQuery` only**, since no other intent
|
||||
reaches `queryWalk`. And `headsThreshold` is 0.6, the measured knee: every value
|
||||
to 0.85 drops right answers and keeps the same two wrong ones.
|
||||
|
||||
`voice.embedder.heads_path` is the whole switch. Empty, missing or unloadable
|
||||
means the heads are nil and the cascade is byte-for-byte what shipped before
|
||||
them. **It must never be pointed at `model_path`.** The resident e5-small must
|
||||
not be replaced by the fine-tuned copy. Recall depends on that file scoring
|
||||
what it scored.
|
||||
|
||||
**The hand-written tokenizer read every long word backwards** until this task
|
||||
(`encodeWord`, `onnxembedder.go`). It cost recall@1 7.4 points and recall@3 11.1.
|
||||
Nothing caught it because seeds and queries were mangled the same way, so cosine
|
||||
survived. The heads found it. They are trained through transformers and read
|
||||
through this. The embedder id now carries a tokenizer revision
|
||||
(`@384/tok2`), so fixing the tokenizer triggers `ReembedAll` the way swapping the
|
||||
model file does. Bump `tokenizerRev` on any change to what it emits.
|
||||
|
||||
`Confidence: 1.0` used to be hardcoded in `llmrouter.go`, so the LLM
|
||||
path could never ask for clarification (6/6 refusal cases missed on the fixture) — Vikunja
|
||||
|
||||
@@ -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"))
|
||||
|
||||
+12
-2
@@ -489,15 +489,19 @@ func (h *reactiveHandler) noteSuspended(ctx context.Context, q *dialogue.Pending
|
||||
if !q.CanResume() {
|
||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||
h.noteDropped(ctx)
|
||||
log.Printf("voice: clarify — the question about %s stepped aside %d times; letting the request go", q.Missing[0], q.Suspends)
|
||||
log.Printf("voice: clarify — letting the question about %s go: %d asides in a row, %d rides in all", q.Missing[0], q.Suspends, q.Rides)
|
||||
return
|
||||
}
|
||||
q.Suspends++
|
||||
// Rides is the same event counted without the reset (V-663). Incremented
|
||||
// beside Suspends and never anywhere else, so the two cannot disagree about
|
||||
// what happened, only about how much of it they remember.
|
||||
q.Rides++
|
||||
q.Asked = h.now()
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), q)
|
||||
rt.resume = question
|
||||
rt.suspended = true
|
||||
log.Printf("voice: clarify — is its own request; suspending the question about %s and resuming it in the same reply (suspend %d of %d)", q.Missing[0], q.Suspends, dialogue.MaxSuspends)
|
||||
log.Printf("voice: clarify — is its own request; suspending the question about %s and resuming it in the same reply (suspend %d of %d, ride %d of %d)", q.Missing[0], q.Suspends, dialogue.MaxSuspends, q.Rides, dialogue.MaxRides)
|
||||
}
|
||||
|
||||
// foldAnswerIntoUtterance appends an answered subject to the original words,
|
||||
@@ -540,6 +544,11 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
|
||||
// Suspends is not carried, and by this point it is already zero: the answer
|
||||
// path resets it (V-654). Left off the literal so the zero is stated where
|
||||
// the struct is built, rather than inherited from a field nobody names.
|
||||
//
|
||||
// Rides IS carried, and that is the whole point of it (V-663). This is the
|
||||
// same request under a second question, not a new one, so the turns it has
|
||||
// already ridden still count against it. Dropping the field here is exactly
|
||||
// the re-basing that let one question ride twenty-six replies.
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
||||
Intent: q.Intent,
|
||||
Slots: merged,
|
||||
@@ -550,6 +559,7 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
|
||||
TTL: clarifyTTL,
|
||||
Attempts: q.Attempts + 1,
|
||||
MaxAttempts: q.MaxAttempts,
|
||||
Rides: q.Rides,
|
||||
})
|
||||
log.Printf("voice: clarify — one gap filled, still missing %s for intent=%s, asking again (attempt %d)", remaining[0], intent, q.Attempts+1)
|
||||
return question, true
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,28 @@ func carriesReminderVerb(text string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isPleasantry matches the WHOLE utterance against lexicon.Pleasantries, after
|
||||
// lowercasing and dropping the punctuation a greeting carries.
|
||||
//
|
||||
// Whole utterance and not tokens. Every token rule tried here was wrong on
|
||||
// something: "вечер" answers "это утра или вечера?", "нет" answers a confirm,
|
||||
// and "спокойной" alone is not an utterance at all. A greeting is a fixed
|
||||
// phrase, so matching it as one costs nothing and claims nothing else.
|
||||
func isPleasantry(text string) bool {
|
||||
t := strings.ToLower(strings.TrimSpace(text))
|
||||
t = strings.Trim(t, " .,!?…")
|
||||
t = strings.Join(strings.Fields(t), " ")
|
||||
if t == "" {
|
||||
return false
|
||||
}
|
||||
for _, p := range lexicon.Pleasantries() {
|
||||
if t == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// offlineOwnRequest is the shape half of the evidence: the offline token tests,
|
||||
// which cost nothing and never depend on the model that produced the routing.
|
||||
// It is also the whole answer when there is no route to read — the classifier
|
||||
@@ -225,6 +247,16 @@ func classifyTurnRole(q *dialogue.PendingQuestion, text string, answer dialogue.
|
||||
// hour, and no route saying "question" changes that. It works because the
|
||||
// extractor no longer reads a day word as the current clock, so a sentence
|
||||
// that names no hour now fills nothing to weigh.
|
||||
// A pleasantry is neither (V-663). "спасибо" and "привет" fell through to
|
||||
// roleAnswer, so a question about a reminder's DAY was re-asked at a man
|
||||
// saying thank you, and the retry it spent was one of the three bounds
|
||||
// meant to end the ride. It is an aside: answered as itself, the question
|
||||
// resumed on the tail, no attempt spent, one ride counted. Placed above the
|
||||
// content gate because "доброе утро" has content and states nothing, so
|
||||
// neither half of the evidence below can reach it.
|
||||
if q != nil && isPleasantry(text) {
|
||||
return roleAside
|
||||
}
|
||||
own := false
|
||||
if len(ownContent(text)) > 0 {
|
||||
own = offlineOwnRequest(text) || (ok && carriesOwnRequest(routed, text))
|
||||
|
||||
@@ -372,4 +372,69 @@ func TestAnAnsweredGapResetsTheSuspendBudget(t *testing.T) {
|
||||
if q.Suspends != 0 {
|
||||
t.Fatalf("answering a gap must reset the suspend budget: suspends = %d", q.Suspends)
|
||||
}
|
||||
// The ride it already took is carried across the re-park (V-663). Resetting
|
||||
// both counters here is what let one question ride twenty-six replies.
|
||||
if q.Rides != 1 {
|
||||
t.Fatalf("the aside it already took was forgotten: rides = %d", q.Rides)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTwoBoundsCannotRearmEachOther — V-663.
|
||||
//
|
||||
// MaxSuspends landed and the measurement did not move: twenty-six of 140 turns
|
||||
// carried a tail before it and twenty-six after. This is the shape it misses,
|
||||
// taken from the 2026-08-08 run, where one question rode turns 7 to 13.
|
||||
//
|
||||
// An aside spends no attempt, so MaxAttempts never reaches it. A turn that
|
||||
// reads as a failed answer zeroes Suspends, so MaxSuspends never reaches the
|
||||
// asides either. Alternating the two rearms each bound with the other's
|
||||
// traffic. Rides counts both kinds and is never reset, so it is what ends this.
|
||||
func TestTwoBoundsCannotRearmEachOther(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _ := newRoutingClarifyHandler(t)
|
||||
id := dialogueIDFor(sourceText, "web")
|
||||
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
|
||||
|
||||
if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") {
|
||||
t.Fatalf("expected the time question, got %q", reply)
|
||||
}
|
||||
|
||||
// Two asides. Each one rides and neither spends an attempt.
|
||||
for i := 0; i < 2; i++ {
|
||||
reply := h.handleText(ctx, "web", "какие у меня напоминания?")
|
||||
if !strings.HasSuffix(reply, resumed) {
|
||||
t.Fatalf("aside %d: the question must come back, got %q", i+1, reply)
|
||||
}
|
||||
}
|
||||
q := h.clarifyStore.Get(id, h.now())
|
||||
if q == nil || q.Rides != 2 || q.Suspends != 2 {
|
||||
t.Fatalf("after two asides: %+v", q)
|
||||
}
|
||||
|
||||
// A pleasantry. It used to read as a failed answer, so she re-asked the
|
||||
// question at a man saying thank you and spent an attempt doing it. Now it
|
||||
// is an aside: answered as itself, question on the tail, one more ride.
|
||||
reply := h.handleText(ctx, "web", "спасибо")
|
||||
if !strings.HasSuffix(reply, resumed) {
|
||||
t.Fatalf("a pleasantry lost the parked question: %q", reply)
|
||||
}
|
||||
q = h.clarifyStore.Get(id, h.now())
|
||||
if q == nil || q.Attempts != 1 {
|
||||
t.Fatalf("a pleasantry spent an attempt: %+v", q)
|
||||
}
|
||||
if q.Rides != 3 {
|
||||
t.Fatalf("a pleasantry rode free: %+v", q)
|
||||
}
|
||||
|
||||
// One more ride of any kind and the request goes, out loud.
|
||||
reply = h.handleText(ctx, "web", "какие у меня напоминания?")
|
||||
if !strings.Contains(reply, clarifyDropped) {
|
||||
t.Fatalf("the question rode four asides and was let go in silence: %q", reply)
|
||||
}
|
||||
if strings.HasSuffix(reply, resumed) {
|
||||
t.Fatalf("a question she has let go must not be asked again: %q", reply)
|
||||
}
|
||||
if h.clarifyStore.Get(id, h.now()) != nil {
|
||||
t.Fatal("the question must be gone once she has said she let it go")
|
||||
}
|
||||
}
|
||||
|
||||
+76
-4
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -231,7 +231,8 @@
|
||||
"embedder": {
|
||||
"model_path": "/opt/maven/models/embedder/multilingual-e5-small/model_quantized.onnx",
|
||||
"tokenizer_path": "/opt/maven/models/embedder/multilingual-e5-small/tokenizer.json",
|
||||
"lib_path": "/opt/maven/lib/libonnxruntime.so"
|
||||
"lib_path": "/opt/maven/lib/libonnxruntime.so",
|
||||
"heads_path": "/opt/maven/models/embedder/router-heads/router_heads.onnx"
|
||||
},
|
||||
"llm_router": true,
|
||||
"query_min_score": 0.55,
|
||||
|
||||
@@ -308,6 +308,29 @@ The count is of CONSECUTIVE step-asides. It resets the moment he answers, in
|
||||
too. "Позвонить маме" against a question about the time is still him in the
|
||||
exchange. The retry it costs is bound enough on its own.
|
||||
|
||||
#### And it may ride four turns in all
|
||||
|
||||
Decided 2026-08-08 (V-663), because the bound above did not move the number it
|
||||
was written for. Twenty-six of 140 turns carried a tail before it landed and
|
||||
twenty-six carried one after.
|
||||
|
||||
Two bounds rearm each other. An aside spends no attempt, so `MaxAttempts` never
|
||||
reaches it. A turn that reads as a failed answer zeroes `Suspends`, so
|
||||
`MaxSuspends` never reaches the asides. Alternating them, each bound is restored
|
||||
by the other's traffic. Measured on 2026-08-08: one question about a reminder's
|
||||
day rode turns 7 to 13. It ended only because turn 14 was a new request.
|
||||
|
||||
`PendingQuestion.Rides` counts the same event as `Suspends` with the resets
|
||||
taken out. It is set once, incremented only in `noteSuspended`, carried across
|
||||
the re-park in `askRemainingGap`, and read by nothing that could lower it.
|
||||
`MaxRides` is 4, one looser than `MaxSuspends` so that the tighter statement
|
||||
about a run stays reachable.
|
||||
|
||||
This is a bound, not a cure. It ends the measured ride one turn early. Most of
|
||||
that ride's length is attempts, spent because `classifyTurnRole` reads "спасибо"
|
||||
and "привет" as failed answers to a question about a day. That is the next
|
||||
thing to fix and it is not a bound.
|
||||
|
||||
The re-ask is also two sentences rather than one. It used to be spliced onto the
|
||||
answer with a comma. On a real answer that buries the question in the tail of
|
||||
one run-on thought:
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# The parked clarify ride, bounded and re-measured
|
||||
|
||||
Date: 2026-08-08, V-663. Same 140 turns, same driver, third and fourth runs of
|
||||
the day. Before is `d6f3914`, after is that plus two changes.
|
||||
|
||||
## What was measured before
|
||||
|
||||
One question about a reminder's day rode turns 6 to 13. It ended only because
|
||||
turn 14 was a new request. Two of those turns are the worst replies in the
|
||||
corpus:
|
||||
|
||||
```text
|
||||
спасибо -> Сейчас 21:25. В какой день?
|
||||
привет -> Сейчас 21:25. В какой день?
|
||||
```
|
||||
|
||||
V-654 had already added `MaxSuspends` and the tail count had not moved.
|
||||
|
||||
## Why three bounds let it happen
|
||||
|
||||
The TTL, `MaxAttempts` and `MaxSuspends` all exist and all were rearmed.
|
||||
|
||||
An aside spends no attempt, so `MaxAttempts` never reaches it. A turn reading as
|
||||
a failed answer zeroes `Suspends`, so `MaxSuspends` never reaches the asides.
|
||||
`спасибо` read as a failed answer. So the pleasantry both produced the reply
|
||||
above and paid for it out of the budget meant to end the ride.
|
||||
|
||||
## The two changes
|
||||
|
||||
`PendingQuestion.Rides` counts the same event as `Suspends` with the resets
|
||||
taken out. Set once, incremented only in `noteSuspended`, carried across the
|
||||
re-park in `askRemainingGap`. `MaxRides` is 4, one looser than `MaxSuspends` so
|
||||
that the tighter statement about a run stays reachable.
|
||||
|
||||
A pleasantry is an aside, not a failed answer. The set is a new closed lexicon
|
||||
entry, matched as whole utterances rather than tokens. `вечер` answers `это утра
|
||||
или вечера?` and `нет` answers a confirm. Anything that could fill a slot stays
|
||||
out.
|
||||
|
||||
## Result
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| turns carrying a clarify tail | 21 | 17 |
|
||||
| turns carrying any failure string | 31 | 29 |
|
||||
| the longest ride | 8 turns | 4 turns |
|
||||
|
||||
The turns carrying a tail, by number:
|
||||
|
||||
```text
|
||||
before 6 7 8 9 10 11 12 13 53 54 55 56 57 99 102 103 116 136 137 138 139
|
||||
after 6 7 8 9 53 54 55 56 57 99 100 101 116 136 137 138 139
|
||||
```
|
||||
|
||||
Turn 10 is the change. It now reads:
|
||||
|
||||
```text
|
||||
спасибо -> Прошлую просьбу отпускаю. Пожалуйста, я всегда готова помочь тебе.
|
||||
```
|
||||
|
||||
She lets the request go, says so, and answers the man. Turns 11 to 13 are clean.
|
||||
|
||||
**`MaxRides` is not what fired.** The pleasantry is an aside now, so it no
|
||||
longer breaks the run. `MaxSuspends` reached three on turn 10 and ended it.
|
||||
`Rides` is the backstop for the shape where an answer really does break the run.
|
||||
No turn in this corpus reaches it.
|
||||
|
||||
## What did not move
|
||||
|
||||
Four rides are untouched. Turns 53 to 57 are five consecutive asides against a
|
||||
reminder missing its day. Turn 58 is a new request that drops it. Nothing
|
||||
pleasant appears in that run, so neither change applies. Turns 99 to 101 shifted
|
||||
by one, and 116 and 136 to 139 are unchanged.
|
||||
|
||||
So the fix is worth four turns of twenty-one. What is left is asides against a
|
||||
question the owner never answers. `MaxSuspends` was written for that shape and
|
||||
does bound it, at four turns each.
|
||||
|
||||
## Not attributable
|
||||
|
||||
Latency moved p50 1.1s to 1.5s and p95 2.8s to 3.0s, and the 34.3s outlier in
|
||||
the earlier run is gone. Both runs had the workstation up. Read none of it as
|
||||
caused by this change.
|
||||
|
||||
One unrelated defect appeared in the after run and is recorded here because it
|
||||
is visible in the transcript. Turn 4 answered `Я записала твою привычкуRegarding
|
||||
coffee without sugar.` That is English leaking into a Russian reply with no
|
||||
space in front of it. It is a phrasing defect and it has no task yet.
|
||||
@@ -0,0 +1,146 @@
|
||||
# The routing heads, running in Go
|
||||
|
||||
Date: 2026-08-08. Vikunja V-664.
|
||||
Weights: `router_heads.onnx`, fp32, exported from `heads.pt` on workpc.
|
||||
Fixture: `internal/router/eval/ru_routing_v1.json`, 96 cases, 33 carrying a destination.
|
||||
Runner: `make t PKG=./internal/router/eval/ RUN=TestONNXRoutingHeads`.
|
||||
|
||||
The four heads of V-661 ran nowhere. This is the number they score through the
|
||||
Go cascade. Same fixture and same grammars as `TestONNXBaseline`, and only the
|
||||
middle stage varies.
|
||||
|
||||
## Headline
|
||||
|
||||
| | classifier + ONNX | heads + classifier | gemma-4-12b cascade |
|
||||
|---|---|---|---|
|
||||
| intent | 75.0% (72/96) | **96.9% (93/96)** | 84.4% |
|
||||
| destination | 33.3% (11/33) | **75.8% (25/33)** | 72.7% |
|
||||
| false clarify | 0 | 1 | 2 |
|
||||
| missed clarify | 8 | 1 | 1 |
|
||||
| p50 | 24.5ms | 27.9ms | 329ms |
|
||||
|
||||
A 118M encoder beats the 12B teacher it was distilled from. It wins on both
|
||||
halves of the route, at a twelfth of the latency. The workstation stays the
|
||||
better phraser and is no longer the better router.
|
||||
|
||||
The p50 is not the heads. Most of it is the classifier's own embedder pass on
|
||||
the turns the heads decline, plus process warm-up on the first case. The heads'
|
||||
own forward pass measures 7.3ms on workpc.
|
||||
|
||||
## Two defects were in the way, and the first was not in the heads
|
||||
|
||||
**The tokenizer read every long word backwards.** `encodeWord` backtracks the
|
||||
Viterbi path from the end of a word and prepends each piece. That puts them back
|
||||
in reading order, and a second reverse after the loop undid it. So
|
||||
`query: вода` tokenized to `[0 12 1294 41 12489 2]` where the reference
|
||||
tokenizer gives `[0 41 1294 12 12489 2]`.
|
||||
|
||||
It was found here and only here. The heads were trained through transformers and
|
||||
are read through the hand-written tokenizer. So a mismatch shows up as a score
|
||||
far below what Python measured on the same weights. Nothing else in the suite
|
||||
compares the two.
|
||||
|
||||
Measured on the recall fixture, same 27 cases either way:
|
||||
|
||||
| | reversed | fixed |
|
||||
|---|---|---|
|
||||
| recall@1 | 70.4% (19/27) | **77.8% (21/27)** |
|
||||
| recall@3 | 85.2% (23/27) | **96.3% (26/27)** |
|
||||
| answered after gate | 63.0% | 66.7% |
|
||||
| wrong note on top | 8 | 6 |
|
||||
| false recall | 0/5 | 1/5 |
|
||||
|
||||
The classifier barely moved, 76.0% to 75.0%, and destination 36.4% to 33.3%.
|
||||
Both are one case on 96 and neither is a finding. Seeds and queries were mangled
|
||||
the same way, so cosine survived it. Recall is where it cost, because a stored
|
||||
passage and a live query are different lengths and break differently.
|
||||
|
||||
The one new false recall is the honest cost and it is not being hidden. A
|
||||
sharper embedder scores every candidate higher, including the ones that should
|
||||
have stayed under the gate. That is the same trade `2026-08-04-recall-e5-small.md`
|
||||
recorded when e5-small replaced MiniLM.
|
||||
|
||||
The embedder id now carries a tokenizer revision, `model_quantized@384/tok2`.
|
||||
Stored vectors were written under rev 1 and no longer sit in the same space as a
|
||||
query embedded now. The model file's name never moved, so nothing would have
|
||||
triggered `ReembedAll`. On the box the marker fired on start, and the re-embed
|
||||
rewrote 65 notes and 19 facts in 5 seconds.
|
||||
|
||||
**The clarify head was being thrown away.** It was read only when the intent head
|
||||
cleared its own threshold. That cost 6 of the 8 ambiguous cases. `вода` reads as intent
|
||||
`act` at 0.233 and clarify at 0.983. Burying that handed the turn to the
|
||||
classifier, which routed it confidently and never asked. The clarify head answers
|
||||
a different question, which is whether there is enough here to act on at all. So
|
||||
it decides on its own and decides first.
|
||||
|
||||
| | intent-gated | clarify decides first |
|
||||
|---|---|---|
|
||||
| intent | 90.6% | 96.9% |
|
||||
| missed clarify | 7 | 1 |
|
||||
| false clarify | 0 | 1 |
|
||||
|
||||
## The threshold is measured, not chosen
|
||||
|
||||
Max softmax over the intent head, on the 88 cases carrying an intent:
|
||||
|
||||
| threshold | kept | accuracy kept | wrong kept | right dropped |
|
||||
|---|---|---|---|---|
|
||||
| 0.5 | 84 | 96.4% | 3 | 2 |
|
||||
| **0.6** | **81** | **97.5%** | **2** | **4** |
|
||||
| 0.7 | 75 | 97.3% | 2 | 10 |
|
||||
| 0.8 | 64 | 96.9% | 2 | 21 |
|
||||
| 0.9 | 46 | 100.0% | 0 | 37 |
|
||||
|
||||
0.6 is the knee. Every value from 0.7 to 0.85 drops right answers and keeps the
|
||||
same two wrong ones. 0.9 is the only value that clears them, and it costs 37
|
||||
correct routes to do it.
|
||||
|
||||
## Quantization was measured and rejected
|
||||
|
||||
| build | size | intent | destination | p50 |
|
||||
|---|---|---|---|---|
|
||||
| fp32 | 470MB | 83/88 (94.3%) | 28/33 (84.8%) | 7.3ms |
|
||||
| int8 | 118MB | 79/88 (89.8%) | 26/33 (78.8%) | 4.0ms |
|
||||
| fp16 | 235MB | will not load | — | — |
|
||||
|
||||
Python numbers, on the heads alone rather than through the cascade. int8 costs
|
||||
4.5 points of intent and 6 of destination to save 3ms. The cascade around it has
|
||||
a p50 over a second when the resident model answers. The fp16 graph is broken:
|
||||
`convert_float_to_float16` leaves a Cast node emitting float16 where the graph
|
||||
expects float, and onnxruntime refuses the session. It was not worth fixing.
|
||||
|
||||
The exporter also had to be told to write one file. It splits weights into a
|
||||
`.onnx.data` sidecar by default. This onnxruntime resolves that path against the
|
||||
process working directory rather than the model. A split graph loads from one
|
||||
directory only.
|
||||
|
||||
## What is still wrong
|
||||
|
||||
**Four of the eight destination misses are calendar.** Training cannot move them.
|
||||
The possessive agenda rules claim those cases at stage 0 and name nothing on
|
||||
purpose. That caution was free while nothing downstream could name anything
|
||||
either. It has now cost four points in three separate measurements. The call is
|
||||
the owner's and it is still open.
|
||||
|
||||
**The slot head is exported and not read.** Slots come from the stage-2
|
||||
extractor. Mapping BIO tags back to text needs character offsets the unigram
|
||||
tokenizer does not keep, which is its own piece of work.
|
||||
|
||||
**`поужинал` is a false clarify**, which is the same defect `thinSingleToken`
|
||||
was narrowed for on 2026-08-01, arriving now from a different direction.
|
||||
|
||||
## On the box
|
||||
|
||||
Deployed to homesrv the same day. `voice: routing heads loaded` on start, and
|
||||
`/trace` shows `routing-heads` winning or thinning every turn. The resident model
|
||||
and the classifier are both marked never asked. Live probes:
|
||||
|
||||
```text
|
||||
что такое TCP? -> kiwix a real definition
|
||||
кто такой Линус Торвальдс? -> kiwix a real answer
|
||||
во сколько я лёг вчера -> personal не нашла у тебя такой записи
|
||||
вода -> thinned to clarify at 0.233 / 0.983
|
||||
```
|
||||
|
||||
A missing or broken weights file logs and leaves the heads nil, which is
|
||||
byte-for-byte the cascade that shipped before this.
|
||||
@@ -0,0 +1,631 @@
|
||||
# Raw transcript, two weeks of usage
|
||||
|
||||
## Day 1
|
||||
|
||||
```
|
||||
YOU: доброе утро
|
||||
MAVEN: Я не говорю так сейчас, ведь на улице уже вечер.
|
||||
[2.8s t=218]
|
||||
|
||||
YOU: какой сегодня день?
|
||||
MAVEN: сегодня суббота, 8 августа 2026 года
|
||||
[0.0s t=219]
|
||||
|
||||
YOU: сколько времени?
|
||||
MAVEN: сейчас 21 час 9 минут
|
||||
[0.0s t=220]
|
||||
|
||||
YOU: запиши что я пью кофе без сахара
|
||||
MAVEN: Я записала твою заметку про кофе。
|
||||
[1.5s t=221]
|
||||
|
||||
YOU: мой любимый язык программирования go
|
||||
MAVEN: Я записала твою заметку про Go.
|
||||
[1.5s t=222]
|
||||
|
||||
YOU: напомни в 11:00 позвонить маме
|
||||
MAVEN: Сейчас 21:10. В какой день?
|
||||
[0.4s t=223]
|
||||
|
||||
YOU: что у меня сегодня?
|
||||
MAVEN: на 08.08.2026 ничего нет. На какое время поставить напоминание?
|
||||
[2.5s t=224]
|
||||
|
||||
YOU: что такое TCP?
|
||||
MAVEN: вот что я нашла: TCP (Transmission Control Protocol) — это транспортный протокол для передачи данных. Он обеспечивает доставку всех пакетов и позволяет собрать их в правильном порядке. Также TCP является частью сетевой модели TCP/IP, которая описывает способ передачи цифровых данных от источника к получателю. На какое время поставить напоминание?
|
||||
[2.8s t=225]
|
||||
|
||||
YOU: сколько будет 17 на 23?
|
||||
MAVEN: вот что я нашла: в источниках нет результата умножения 17 на 23. На какое время поставить напоминание?
|
||||
[1.6s t=226]
|
||||
|
||||
YOU: спасибо
|
||||
MAVEN: Сейчас 21:10. В какой день?
|
||||
[1.1s t=227]
|
||||
|
||||
```
|
||||
|
||||
## Day 2
|
||||
|
||||
```
|
||||
YOU: привет
|
||||
MAVEN: Сейчас 21:10. В какой день?
|
||||
[0.9s t=228]
|
||||
|
||||
YOU: что нового?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи. На какое время поставить напоминание?
|
||||
[1.3s t=229]
|
||||
|
||||
YOU: какая погода?
|
||||
MAVEN: для какого города? На какое время поставить напоминание?
|
||||
[0.7s t=230]
|
||||
|
||||
YOU: запиши что пароль от вайфая лежит в ящике стола
|
||||
MAVEN: Прошлую просьбу отпускаю. Я записала, что пароль от вай-фай лежит в ящике стола.
|
||||
[1.5s t=231]
|
||||
|
||||
YOU: где лежит вайфай пароль?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||
[0.5s t=232]
|
||||
|
||||
YOU: добавь молоко в список покупок
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[1.0s t=233]
|
||||
|
||||
YOU: что у меня в списке покупок?
|
||||
MAVEN: в списке покупок пусто.
|
||||
[0.0s t=234]
|
||||
|
||||
YOU: кто такой Линус Торвальдс?
|
||||
MAVEN: вот что я нашла: Линус Торвальдс — финско-американский программист из Хельсинки, создатель ядра Linux. Он родился 28 декабря 1969 года и окончил факультет компьютерных наук Хельсинкского университета.
|
||||
[2.6s t=235]
|
||||
|
||||
YOU: какой у меня любимый язык?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||
[0.6s t=236]
|
||||
|
||||
YOU: сколько у меня задач?
|
||||
MAVEN: сначала: проверить интейк форму на боксе (завтра); купить хлеб; купить молоко; полить цветы; починить кран
|
||||
[0.0s t=237]
|
||||
|
||||
```
|
||||
|
||||
## Day 3
|
||||
|
||||
```
|
||||
YOU: как дела?
|
||||
MAVEN: У меня всё хорошо, я готова помогать тебе. Чем могу быть полезна?
|
||||
[1.2s t=238]
|
||||
|
||||
YOU: напомни завтра в 9 утра купить хлеб
|
||||
MAVEN: хорошо, напомню завтра в 09:00.
|
||||
[0.3s t=239]
|
||||
|
||||
YOU: что у меня завтра?
|
||||
MAVEN: на 09.08.2026 ничего нет.
|
||||
[0.0s t=240]
|
||||
|
||||
YOU: отмени напоминание про хлеб
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[0.5s t=241]
|
||||
|
||||
YOU: какие у меня напоминания?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о твоих напоминаниях.
|
||||
[0.7s t=242]
|
||||
|
||||
YOU: сохрани мне адрес гостиницы в Сочи
|
||||
MAVEN: Я записала твой адрес отеля в Сочи.
|
||||
[2.2s t=243]
|
||||
|
||||
YOU: что я сохранил про Сочи?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о том, что ты сохранил про Сочи.
|
||||
[1.5s t=244]
|
||||
|
||||
YOU: почему сервер тормозит?
|
||||
MAVEN: вот что я нашла: сервер может тормозить из-за неудачной конфигурации или неоптимальных настроек, отсутствия кэширования, неоптимизированного программного обеспечения, которое нужно обновлять, а также из-за режима энергосбережения процессора, когда он не успевает повысить частоту при нагрузке.
|
||||
[3.7s t=245]
|
||||
|
||||
YOU: хватает ли места под новые бэкапы?
|
||||
MAVEN: нового вот что: [$] Changes in shadow-utils password-expiration features; The Software Stewardship Lab launches; LightDM lives: version 1.33.0 released
|
||||
[0.7s t=246]
|
||||
|
||||
YOU: выключи свет в спальне
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[0.4s t=247]
|
||||
|
||||
```
|
||||
|
||||
## Day 4
|
||||
|
||||
```
|
||||
YOU: доброе утро
|
||||
MAVEN: Сейчас уже вечер, поэтому я не могу сказать тебе «доброе утро».
|
||||
[1.4s t=248]
|
||||
|
||||
YOU: что я пропустил?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о том, что ты пропустил.
|
||||
[1.3s t=249]
|
||||
|
||||
YOU: о чём мы вчера говорили?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||
[1.0s t=250]
|
||||
|
||||
YOU: запиши что я записался к врачу на четверг
|
||||
MAVEN: Я записала твою запись к врачу.
|
||||
[1.2s t=251]
|
||||
|
||||
YOU: когда я иду к врачу?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о том, когда именно ты идешь к врачу.
|
||||
[2.1s t=252]
|
||||
|
||||
YOU: что такое ZFS?
|
||||
MAVEN: вот что я нашла: ZFS — это современная и мощная файловая система, в которой сочетаются безопасность, масштабируемость и удобство управления данными. Она была разработана корпорацией Sun Microsystems и объединяет в себе файловую систему с менеджером томов. Изначально она создавалась для того, чтобы преодолеть недостатки традиционных RAID-массивов, такие как проблема «дыры записи».
|
||||
[2.1s t=253]
|
||||
|
||||
YOU: столица Франции?
|
||||
MAVEN: вот что я нашла: столица Франции — Париж.
|
||||
[3.7s t=254]
|
||||
|
||||
YOU: переведи слово ремонт на английский
|
||||
MAVEN: вот что я нашла: слово «ремонт» на английский можно перевести как repair (например, ремонт дороги или крыши) или fix (общее слово для починки техники и мебели).
|
||||
[2.7s t=255]
|
||||
|
||||
YOU: сколько я потратил в этом месяце?
|
||||
MAVEN: я не отслеживаю траты — не подключено.
|
||||
[0.7s t=256]
|
||||
|
||||
YOU: спокойной ночи
|
||||
MAVEN: Спокойной ночи. Я уже подготовила всё к твоему отдыху.
|
||||
[1.2s t=257]
|
||||
|
||||
```
|
||||
|
||||
## Day 5
|
||||
|
||||
```
|
||||
YOU: привет
|
||||
MAVEN: Привет! Я готова помогать тебе сегодня.
|
||||
[1.0s t=258]
|
||||
|
||||
YOU: какая погода в Москве?
|
||||
MAVEN: погода не настроена.
|
||||
[0.9s t=259]
|
||||
|
||||
YOU: что там с бэкапами?
|
||||
MAVEN: вот что я нашла: бэкап — это резервная копия данных, созданная в определенный момент времени и сохраненная на локальном или облачном носителе. Его делают, чтобы быстро восстановить информацию в случае необходимости.
|
||||
[2.3s t=260]
|
||||
|
||||
YOU: покажи что требует внимания
|
||||
MAVEN: мне пока нечего смотреть — у Praxis нет источников.
|
||||
[0.5s t=261]
|
||||
|
||||
YOU: отметь это как сделанное
|
||||
MAVEN: команды «отметь» пока нет в списке — она уже предложена, включи через клиент.
|
||||
[0.0s t=262]
|
||||
|
||||
YOU: запиши что я купил новые наушники
|
||||
MAVEN: Я записала твою покупку новых наушников.
|
||||
[1.8s t=263]
|
||||
|
||||
YOU: какие у меня заметки за неделю?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||
[0.0s t=264]
|
||||
|
||||
YOU: расскажи про Kubernetes
|
||||
MAVEN: вот что я нашла: Kubernetes — это портативная расширяемая платформа с открытым исходным кодом для управления сервисами и контейнеризованными рабочими нагрузками. Управлять им можно через интерфейс в командной строке kubectl. Я нашла эти данные в предоставленных источниках.
|
||||
[1.8s t=265]
|
||||
|
||||
YOU: кто я?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о том, кто ты.
|
||||
[1.4s t=266]
|
||||
|
||||
YOU: пока
|
||||
MAVEN: До свидания, пока!
|
||||
[1.4s t=267]
|
||||
|
||||
```
|
||||
|
||||
## Day 6
|
||||
|
||||
```
|
||||
YOU: доброе утро
|
||||
MAVEN: Сейчас уже вечер, так что доброе утро я тебе сказать не могу. Я готова помочь тебе с чем-нибудь другим.
|
||||
[1.4s t=268]
|
||||
|
||||
YOU: сколько времени?
|
||||
MAVEN: сейчас 21 час 11 минут
|
||||
[0.0s t=269]
|
||||
|
||||
YOU: напомни в 18:30 позвонить в банк
|
||||
MAVEN: Сейчас 21:11. В какой день?
|
||||
[0.3s t=270]
|
||||
|
||||
YOU: поставь чайник
|
||||
MAVEN: Сейчас 21:11. В какой день?
|
||||
[1.1s t=271]
|
||||
|
||||
YOU: включи музыку
|
||||
MAVEN: Сейчас 21:11. В какой день?
|
||||
[0.9s t=272]
|
||||
|
||||
YOU: что у меня в календаре на пятницу?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о твоих личных событиях в календаре на пятницу. В одном из текстов упоминается пятница 24 июля 2026 года, а в другом — что сегодня пятница 7 августа 2026 года. На какое время поставить напоминание?
|
||||
[2.6s t=273]
|
||||
|
||||
YOU: во сколько у меня встреча?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи. На какое время поставить напоминание?
|
||||
[0.3s t=274]
|
||||
|
||||
YOU: запиши что дедлайн по проекту в понедельник
|
||||
MAVEN: Прошлую просьбу отпускаю. Я записала твой дедлайн на понедельник.
|
||||
[1.7s t=275]
|
||||
|
||||
YOU: успею ли я до дедлайна?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о твоем дедлайне, поэтому я не могу ответить, успеешь ты или нет.
|
||||
[2.0s t=276]
|
||||
|
||||
YOU: спасибо
|
||||
MAVEN: Пожалуйста, я всегда готова помочь тебе.
|
||||
[1.4s t=277]
|
||||
|
||||
```
|
||||
|
||||
## Day 7
|
||||
|
||||
```
|
||||
YOU: привет
|
||||
MAVEN: Привет! Чем я могу тебе помочь?
|
||||
[1.3s t=278]
|
||||
|
||||
YOU: как ты?
|
||||
MAVEN: Я в порядке, спасибо, что спросил. Готова помогать тебе!
|
||||
[1.4s t=279]
|
||||
|
||||
YOU: расскажи анекдот
|
||||
MAVEN: вот что я нашла: в источниках есть ссылки на сборники и сайты с анекдотами, но самих текстов анекдотов там нет, кроме одного: ударилась лягушка о землю головой и стала девицей прекрасной, а второй раз — месивом кровавым.
|
||||
[3.9s t=280]
|
||||
|
||||
YOU: что ты умеешь?
|
||||
MAVEN: Я Мэйвен, твоя помощница. Я живу на твоём сервере и отвечаю на твои вопросы. Я запоминаю то, что ты мне говоришь, веду заметки, ставлю напоминания, а также читаю твой календарь и задачи. Я отвечаю на вопросы о мире, используя поиск или офлайн-энциклопедию. Если ты разрешишь, я могу управлять домом, локальной сетью, лентами, списком покупок и погодой, а также писать в телеграм. Я говорю по-русски и по-английски.
|
||||
[2.9s t=281]
|
||||
|
||||
YOU: запиши что я начал бегать по утрам
|
||||
MAVEN: Я записала твою новую привычку
|
||||
[1.5s t=282]
|
||||
|
||||
YOU: я бегаю по утрам уже неделю
|
||||
MAVEN: записала: я бегаю по утрам уже неделю
|
||||
[0.6s t=283]
|
||||
|
||||
YOU: как часто я бегаю?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о том, как часто бегаешь ты. Там указано, что для похудения частоту тренировок можно доводить до 5–6 раз в неделю, а новичкам ежедневные тренировки без подготовки могут привести к травмам.
|
||||
[3.3s t=284]
|
||||
|
||||
YOU: сколько стоит биткоин?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о стоимости биткоина.
|
||||
[1.2s t=285]
|
||||
|
||||
YOU: какие новости?
|
||||
MAVEN: вот что нового: [$] Changes in shadow-utils password-expiration features; The Software Stewardship Lab launches; LightDM lives: version 1.33.0 released
|
||||
[0.8s t=286]
|
||||
|
||||
YOU: хорошего дня
|
||||
MAVEN: Я не могу сказать «хорошего дня», так как сейчас уже вечер. Хорошего отдыха!
|
||||
[1.3s t=287]
|
||||
|
||||
```
|
||||
|
||||
## Day 8
|
||||
|
||||
```
|
||||
YOU: доброе утро
|
||||
MAVEN: Сейчас вечер, так что доброе утро я не скажу. Чем я могу тебе помочь?
|
||||
[1.2s t=288]
|
||||
|
||||
YOU: что у меня сегодня?
|
||||
MAVEN: на 08.08.2026 ничего нет.
|
||||
[0.0s t=289]
|
||||
|
||||
YOU: напомни через час выпить воды
|
||||
MAVEN: хорошо, напомню сегодня в 22:11.
|
||||
[0.3s t=290]
|
||||
|
||||
YOU: я выпил воды
|
||||
MAVEN: записала: я выпил воды
|
||||
[0.9s t=291]
|
||||
|
||||
YOU: запиши что кот ест только сухой корм
|
||||
MAVEN: Я записала твою заметку про кота.
|
||||
[1.2s t=292]
|
||||
|
||||
YOU: чем питается кот?
|
||||
MAVEN: вот что я нашла: в природе кошки едят мышей, птиц и ящериц. Дома их правильно кормить мясом, мясными субпродуктами, яйцами и нежирной морской рыбой. Из-за особенностей пищеварения им не подходят каши, макароны и мучные изделия.
|
||||
[3.4s t=293]
|
||||
|
||||
YOU: что такое DNS?
|
||||
MAVEN: вот что я нашла: DNS (Domain Name System) — это распределенная база данных, которая сопоставляет домены с IP-адресами. Это не единая база на одном сервере, а иерархическая система серверов, работающих совместно. В ней хранятся записи, которые обеспечивают маршрутизацию трафика и управление электронной почтой.
|
||||
[2.0s t=294]
|
||||
|
||||
YOU: проверь статус uptime kuma
|
||||
MAVEN: uptime kuma — Проверяю статус Uptime Kuma...
|
||||
[1.1s t=295]
|
||||
|
||||
YOU: всё ли в порядке с сервером?
|
||||
MAVEN: мне пока нечего смотреть — у Praxis нет источников.
|
||||
[0.5s t=296]
|
||||
|
||||
YOU: спасибо
|
||||
MAVEN: Пожалуйста, я всегда рада помочь тебе.
|
||||
[2.7s t=297]
|
||||
|
||||
```
|
||||
|
||||
## Day 9
|
||||
|
||||
```
|
||||
YOU: привет
|
||||
MAVEN: Привет! Чем я могу тебе помочь сегодня?
|
||||
[1.9s t=298]
|
||||
|
||||
YOU: какой сегодня день недели?
|
||||
MAVEN: сегодня суббота, 8 августа 2026 года
|
||||
[0.0s t=299]
|
||||
|
||||
YOU: добавь хлеб и сыр в список покупок
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[0.6s t=300]
|
||||
|
||||
YOU: что в списке покупок?
|
||||
MAVEN: в списке покупок пусто.
|
||||
[0.0s t=301]
|
||||
|
||||
YOU: удали молоко из списка
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[0.4s t=302]
|
||||
|
||||
YOU: напомни завтра утром вынести мусор
|
||||
MAVEN: Сейчас 21:11. Когда?
|
||||
[0.3s t=303]
|
||||
|
||||
YOU: запиши что я поменял масло в машине
|
||||
MAVEN: Прошлую просьбу отпускаю. записала: запиши что я поменял масло в машине
|
||||
[0.7s t=304]
|
||||
|
||||
YOU: когда я менял масло?
|
||||
MAVEN: масло в машине — поменял
|
||||
[0.4s t=305]
|
||||
|
||||
YOU: сколько будет 144 делить на 12?
|
||||
MAVEN: вот что я нашла: в источниках указано решение деления 144 на 12 в столбик, но итоговый результат деления не прописан.
|
||||
[2.3s t=306]
|
||||
|
||||
YOU: пока
|
||||
MAVEN: До свидания! Если понадобится что-то записать или проверить, я всегда на связи.
|
||||
[1.2s t=307]
|
||||
|
||||
```
|
||||
|
||||
## Day 10
|
||||
|
||||
```
|
||||
YOU: доброе утро
|
||||
MAVEN: Я не могу сказать «доброе утро», так как сейчас уже вечер. Я записала твои предыдущие вопросы.
|
||||
[1.8s t=308]
|
||||
|
||||
YOU: что нового за ночь?
|
||||
MAVEN: вот что я нашла: в ночь на 8 августа вооруженные силы РФ нанесли второй за последнее время массированный удар, при этом киевская ПВО пропустила все российские ракеты.
|
||||
[3.3s t=309]
|
||||
|
||||
YOU: почему интернет медленный?
|
||||
MAVEN: вот что я нашла: интернет может работать медленно из-за плохого сигнала из-за удаленности вышки, исчерпанного лимита трафика по тарифу, нахождения вне зоны покрытия 4G/LTE, устаревшей или неисправной SIM-карты, а также из-за сбоя в настройках роутера. Чтобы исправить ситуацию, можно попробовать перезагрузить роутер или перенастроить устройство.
|
||||
[3.0s t=310]
|
||||
|
||||
YOU: какая скорость у меня сейчас?
|
||||
MAVEN: для какого города?
|
||||
[0.6s t=311]
|
||||
|
||||
YOU: запиши что новый роутер стоит 8000 рублей
|
||||
MAVEN: Я записала стоимость нового роутера.
|
||||
[1.2s t=312]
|
||||
|
||||
YOU: сколько стоил роутер?
|
||||
MAVEN: вот что я нашла: цена зависит от модели. Роутер от Ростелеком стоит 5 900 рублей, на OZON есть варианты от 4 052 до 3 251 рублей, а в Билайне цены начинаются от 2 149 рублей.
|
||||
[2.6s t=313]
|
||||
|
||||
YOU: что такое NAT?
|
||||
MAVEN: вот что я нашла: NAT (Network Address Translation) — это технология в сетях TCP/IP, которая позволяет изменять IP-адрес в заголовке пакета при его прохождении через устройство. Она перенаправляет трафик между локальной и глобальной сетями, позволяет объединять устройства в локальной сети под одним публичным IP-адресом, а также скрывает сеть и экономит IP.
|
||||
[2.1s t=314]
|
||||
|
||||
YOU: напомни в субботу позвонить бабушке
|
||||
MAVEN: Сейчас 21:12. Когда?
|
||||
[0.3s t=315]
|
||||
|
||||
YOU: покажи мои напоминания
|
||||
MAVEN: вот что я нашла: в источниках нет информации о твоих конкретных напоминаниях, там только описано, как их создавать и где искать в приложениях. На какое время поставить напоминание?
|
||||
[3.0s t=316]
|
||||
|
||||
YOU: спасибо
|
||||
MAVEN: Сейчас 21:12. Во сколько напомнить?
|
||||
[1.0s t=317]
|
||||
|
||||
```
|
||||
|
||||
## Day 11
|
||||
|
||||
```
|
||||
YOU: привет
|
||||
MAVEN: Сейчас 21:12. Скажи время — например, «в семь вечера» или «через час».
|
||||
[0.8s t=318]
|
||||
|
||||
YOU: как погода на выходных?
|
||||
MAVEN: для какого города? На какое время поставить напоминание?
|
||||
[0.7s t=319]
|
||||
|
||||
YOU: что у меня на этой неделе?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о том, что у тебя на этой неделе. На какое время поставить напоминание?
|
||||
[1.0s t=320]
|
||||
|
||||
YOU: запиши что я хочу прочитать книгу про Go
|
||||
MAVEN: Прошлую просьбу отпускаю. Я записала твою заметку о книге по Go.
|
||||
[1.6s t=321]
|
||||
|
||||
YOU: что я хотел прочитать?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||
[0.9s t=322]
|
||||
|
||||
YOU: объясни что такое горутина
|
||||
MAVEN: вот что я нашла: я проверила источники и узнала, что горутины — это легковесные потоки выполнения или функции, которые позволяют параллельно выполнять код внутри программы. Они являются легкими нитями, которые создаются и планируются для независимого выполнения планировщиком Go.
|
||||
[2.5s t=323]
|
||||
|
||||
YOU: кто написал Войну и мир?
|
||||
MAVEN: вот что я нашла: роман «Война и мир» написал Лев Николаевич Толстой.
|
||||
[2.2s t=324]
|
||||
|
||||
YOU: включи свет на кухне
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[0.5s t=325]
|
||||
|
||||
YOU: закрой шторы в комнате
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[0.4s t=326]
|
||||
|
||||
YOU: спокойной ночи
|
||||
MAVEN: Спокойной ночи! Я уже подготовила всё к твоему отдыху.
|
||||
[1.1s t=327]
|
||||
|
||||
```
|
||||
|
||||
## Day 12
|
||||
|
||||
```
|
||||
YOU: доброе утро
|
||||
MAVEN: Сейчас уже вечер, поэтому я не могу сказать тебе «доброе утро».
|
||||
[1.1s t=328]
|
||||
|
||||
YOU: сколько сейчас времени?
|
||||
MAVEN: сейчас 21 час 12 минут
|
||||
[0.0s t=329]
|
||||
|
||||
YOU: я не то имел в виду
|
||||
MAVEN: Я поняла тебя, исправь, пожалуйста, свою мысль. Что именно ты хотел сказать?
|
||||
[1.8s t=330]
|
||||
|
||||
YOU: о чём мы говорили?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||
[0.6s t=331]
|
||||
|
||||
YOU: напомни
|
||||
MAVEN: Сейчас 21:12. Когда?
|
||||
[0.5s t=332]
|
||||
|
||||
YOU: сделай это
|
||||
MAVEN: пока не умею отвечать на этот вопрос. На какое время поставить напоминание?
|
||||
[0.5s t=333]
|
||||
|
||||
YOU: запиши что я перешёл на новый тариф
|
||||
MAVEN: Прошлую просьбу отпускаю. Я записала твою новую информацию о тарифе.
|
||||
[1.5s t=334]
|
||||
|
||||
YOU: какой у меня тариф?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о твоем тарифе.
|
||||
[1.2s t=335]
|
||||
|
||||
YOU: сколько я плачу за интернет?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о том, сколько именно ты платишь за интернет. Я проверила документы, но точной суммы там нет.
|
||||
[2.6s t=336]
|
||||
|
||||
YOU: спасибо
|
||||
MAVEN: Я поняла тебя. Что именно ты хочешь, чтобы я сделала сейчас?
|
||||
[1.5s t=337]
|
||||
|
||||
```
|
||||
|
||||
## Day 13
|
||||
|
||||
```
|
||||
YOU: привет
|
||||
MAVEN: Привет! Я готова помогать тебе. Чем займемся сегодня?
|
||||
[1.3s t=338]
|
||||
|
||||
YOU: что там с задачами?
|
||||
MAVEN: вот что я нашла: я отметила задачу про бэкапы как сделанную.
|
||||
[1.5s t=339]
|
||||
|
||||
YOU: закрывай
|
||||
MAVEN: какой пункт отметить сделанным?
|
||||
[0.0s t=340]
|
||||
|
||||
YOU: отметь задачу про бэкапы как сделанную
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[0.6s t=341]
|
||||
|
||||
YOU: что осталось нерешённым?
|
||||
MAVEN: вот что я нашла: в твоей жизни всё, что остается нерешенным или незавершенным, будет продолжать существовать и требовать внимания. Также в источниках упоминается пример с скобками, который может остаться нерешенным, и вопрос о том, что остается нерешенным в краснодарских перевозках.
|
||||
[4.2s t=342]
|
||||
|
||||
YOU: запиши что я договорился о встрече в среду
|
||||
MAVEN: Я записала твою договоренность о встрече.
|
||||
[2.3s t=343]
|
||||
|
||||
YOU: когда у меня встреча?
|
||||
MAVEN: мне пока нечего смотреть — у Praxis нет источников.
|
||||
[0.0s t=344]
|
||||
|
||||
YOU: какая температура на улице?
|
||||
MAVEN: для какого города?
|
||||
[0.9s t=345]
|
||||
|
||||
YOU: что такое RAID 5?
|
||||
MAVEN: вот что я нашла: RAID 5 — это массив из независимых дисков, где данные и контрольные суммы (четность) распределяются по кругу на все диски. Этот уровень обеспечивает отказоустойчивость и повышенную производительность, при этом для его работы требуется не менее трех дисков.
|
||||
[2.3s t=346]
|
||||
|
||||
YOU: пока
|
||||
MAVEN: До встречи!
|
||||
[1.4s t=347]
|
||||
|
||||
```
|
||||
|
||||
## Day 14
|
||||
|
||||
```
|
||||
YOU: доброе утро
|
||||
MAVEN: Я не могу сказать «доброе утро», потому что сейчас вечер. Я уже записала твой вопрос и готова помочь с чем-то другим.
|
||||
[1.7s t=348]
|
||||
|
||||
YOU: подведи итоги недели
|
||||
MAVEN: week summary — подведи итоги недели
|
||||
[0.6s t=349]
|
||||
|
||||
YOU: что я делал за последние две недели?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о том, что ты делал за последние две недели.
|
||||
[1.3s t=350]
|
||||
|
||||
YOU: какие заметки я сохранил?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о том, какие именно заметки ты сохранил.
|
||||
[1.3s t=351]
|
||||
|
||||
YOU: о чём я чаще всего спрашиваю?
|
||||
MAVEN: обычно ты пьёшь воду около 00:58, отмечаешь «name» около 23:56 и отмечаешь «week summary» около 23:59 — по записям за последние 7 дней.
|
||||
[0.9s t=352]
|
||||
|
||||
YOU: напомни в понедельник в 10 проверить бэкапы
|
||||
MAVEN: Сейчас 21:12. Это утра или вечера?
|
||||
[0.3s t=353]
|
||||
|
||||
YOU: что у меня в понедельник?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи. На какое время поставить напоминание?
|
||||
[0.3s t=354]
|
||||
|
||||
YOU: ты меня понимаешь?
|
||||
MAVEN: Да, я тебя прекрасно понимаю. Чем я могу тебе помочь? На какое время поставить напоминание?
|
||||
[1.3s t=355]
|
||||
|
||||
YOU: спасибо тебе
|
||||
MAVEN: Сейчас 21:12. Это утра или вечера?
|
||||
[1.0s t=356]
|
||||
|
||||
YOU: спокойной ночи
|
||||
MAVEN: хорошо, напомню послезавтра в 10:00.
|
||||
[1.2s t=357]
|
||||
|
||||
```
|
||||
@@ -0,0 +1,132 @@
|
||||
# The same fortnight, after V-655 merged
|
||||
|
||||
Date: 2026-08-08, a few hours after `2026-08-08-two-weeks.md`.
|
||||
Build: `f8fa0d1` on master, the five compose services rebuilt and recreated.
|
||||
Same driver, same 140 turns, same reach. This is the diff that baseline was for.
|
||||
|
||||
Master now carries V-655, V-659, V-660 and V-661. The change under test is V-655. A query source that decides by seed similarity
|
||||
is marked `guesses: true`. It is dropped when the cascade names a different
|
||||
destination.
|
||||
|
||||
## Two things confound the comparison and one of them matters
|
||||
|
||||
**The workstation was up for the re-run.** `llama-server` on 192.168.1.105
|
||||
answered a health probe with 200. So routing completed through `llm.Pair`
|
||||
against gemma-4-12b, which is the arm that names a destination. Its state
|
||||
during the baseline was not recorded. So a difference here may be the merge, or
|
||||
may be the better router, and this run cannot separate them.
|
||||
|
||||
**The store carried over**, as the baseline said it would. Facts written by the
|
||||
first run were present from turn 1 of the second.
|
||||
|
||||
## Numbers
|
||||
|
||||
| | baseline `beb093a` | after `f8fa0d1` |
|
||||
|---|---|---|
|
||||
| turns | 140 | 140 |
|
||||
| p50 | 1.5s | 1.2s |
|
||||
| p95 | 7.1s | 3.0s |
|
||||
| max | 33.7s | 4.2s |
|
||||
| transport errors | 0 | 0 |
|
||||
| turns carrying a failure string | 41 | 38 |
|
||||
|
||||
| string in the reply | before | after |
|
||||
|---|---|---|
|
||||
| `на какое время поставить напоминание` | 13 | 13 |
|
||||
| `не нашла у тебя такой записи` | 8 | 9 |
|
||||
| `Такую команду я не знаю` | 8 | 8 |
|
||||
| `для какого города` | 6 | 4 |
|
||||
| `В какой день` | 6 | 6 |
|
||||
| `пока не умею` | 5 | 1 |
|
||||
| `Когда?` | 3 | 3 |
|
||||
|
||||
Read the latency as unattributed. The workstation confound covers all of it.
|
||||
|
||||
## Defect 2 is the one this was for: four of six fixed
|
||||
|
||||
| utterance | before | after |
|
||||
|---|---|---|
|
||||
| `что такое TCP?` | `для какого города?` | a real definition |
|
||||
| `сколько будет 17 на 23?` | `для какого города?` | search, which has no answer |
|
||||
| `какой у меня любимый язык?` | kernel headlines | `не нашла у тебя такой записи` |
|
||||
| `что я сохранил про Сочи?` | `Хорошо, сохраню.` | answered as a question |
|
||||
| `какая скорость у меня сейчас?` | `для какого города?` | `для какого города?` |
|
||||
| `хватает ли места под новые бэкапы?` | kernel headlines | kernel headlines |
|
||||
|
||||
`что такое TCP?` is the clean win. `WorldQueryGrammars` names `world` at stage
|
||||
0, weather is dropped, and search answers.
|
||||
|
||||
`сколько будет 17 на 23?` moved source and not outcome. Weather no longer claims it. Search
|
||||
cannot do arithmetic, so the reply says the sources have no product of 17 and
|
||||
23. That is an honest gap where it used to be a wrong
|
||||
question. Arithmetic has no destination in the enum.
|
||||
|
||||
`что я сохранил про Сочи?` was defect 3 and it is gone. The utterance is no
|
||||
longer read as a capture.
|
||||
|
||||
**The two that did not move are both homelab questions.** They are exactly the
|
||||
cluster the destination fixture flagged. `SourceRecall`, `SourceNetwork` and
|
||||
`SourceAttention` overlap on every question about the box. Five of the seven
|
||||
floor cases in that fixture are homelab operations for the same reason. So this
|
||||
is the enum, not the walk.
|
||||
|
||||
## Defect 1 did not move at all
|
||||
|
||||
Twenty-six turns still carry a parked clarify tail, the same count as the
|
||||
baseline. `спасибо тебе` answers `Сейчас 21:12. Это утра или вечера?` and
|
||||
`спокойной ночи` answers `хорошо, напомню послезавтра в 10:00.`
|
||||
|
||||
V-655 was never going to touch this. A parked clarify is dialogue state and not
|
||||
a query source. It remains the single worst thing about talking to her. The week test, the
|
||||
fortnight test and this re-run all report it unchanged.
|
||||
|
||||
## A gap in the harness, fixed and re-run the same day
|
||||
|
||||
`ipc.ChatReply.Source` came back empty on all 140 turns, in both runs. The
|
||||
driver read the redirect parameter `src` and `cmd/mavweb/chat.go` writes `s`.
|
||||
So every finding above is read off the reply text instead of off the badge.
|
||||
|
||||
Fixed in V-662 and the 140 turns were driven a third time. Sixty-eight of them
|
||||
name a source. The rest are not query turns and never reach `queryWalk`.
|
||||
|
||||
| source | turns |
|
||||
|---|---|
|
||||
| search | 27 |
|
||||
| memory | 13 |
|
||||
| personal | 9 |
|
||||
| weather | 5 |
|
||||
| calendar | 3 |
|
||||
| attention | 3 |
|
||||
| list | 2 |
|
||||
| feeds | 2 |
|
||||
| tasks, money, self, habits | 1 each |
|
||||
|
||||
## What the badge shows that the wording did not
|
||||
|
||||
The two unfixed homelab turns are now direct evidence.
|
||||
|
||||
```text
|
||||
какая скорость у меня сейчас? -> weather
|
||||
хватает ли места под новые бэкапы? -> feeds
|
||||
```
|
||||
|
||||
Both are guessing sources claiming a turn about the box, exactly as the
|
||||
destination fixture predicted.
|
||||
|
||||
The badge also names a defect the wording hid. **Agenda questions are being
|
||||
claimed by the personal boundary and by Praxis, not by the calendar.**
|
||||
|
||||
```text
|
||||
во сколько у меня встреча? -> personal не нашла у тебя такой записи
|
||||
когда у меня встреча? -> attention у Praxis нет источников
|
||||
что у меня в понедельник? -> personal не нашла у тебя такой записи
|
||||
```
|
||||
|
||||
Calendar claimed 3 turns of the 6 that asked about the calendar. That is the
|
||||
same 3/6 the destination fixture scores and the same 3/6 every seed of the
|
||||
routing head scores. Three measurements agree. The cause is the one V-660 named. The possessive
|
||||
agenda rules claim these at stage 0 and name no destination, so the walk
|
||||
reaches `personal` and `attention` first.
|
||||
|
||||
This is the third independent confirmation that the possessive agenda rules
|
||||
should name the calendar. That call is still the owner's.
|
||||
@@ -45,8 +45,8 @@ present when a re-run reaches day 1.
|
||||
**Zero transport errors is not zero wrong answers.** It counts turns that
|
||||
failed to return a reply, and none did. Every quality number is below.
|
||||
|
||||
Those seven strings appear in 49 of 140 turns. Some turns carry two, because a
|
||||
parked clarify appends to whatever else was said.
|
||||
Those seven strings appear 49 times across 41 of 140 turns. Some turns carry
|
||||
two, because a parked clarify appends to whatever else was said.
|
||||
|
||||
The 33.7s outlier is one note write on day 2. p95 improved against the week
|
||||
despite it.
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# CrisperWhisper 2.0 in Russian, measured
|
||||
|
||||
Date: 2026-08-09. Vikunja V-665.
|
||||
Corpus: `bond005/sberdevices_golos_10h_crowd`, test split, first 200 clips.
|
||||
Harness: `~/Programs/cw2-eval` on workpc, not in this repo.
|
||||
Runner: `./.venv/bin/python run_asr.py <arm>...` then `score.py`.
|
||||
|
||||
The model card benchmarks disfluency F1 in German and English. It never names
|
||||
Russian and publishes no per-language WER. So the measurement came before the
|
||||
wiring.
|
||||
|
||||
## The corpus
|
||||
|
||||
200 clips, 13.7 minutes, 1001 reference words. Median clip 3.91s, range 1.04s
|
||||
to 13.5s. Golos crowd is short crowd-sourced Russian spoken close to the
|
||||
microphone, which is the nearest public thing to someone talking to Maven. The
|
||||
alternatives are read speech, which flatters every model equally.
|
||||
|
||||
Two rows carry a null transcription and are skipped.
|
||||
|
||||
Scoring normalizes both sides: lowercase, `ё` to `е`, punctuation stripped, and
|
||||
digits expanded to Russian words through num2words. Without that last step a
|
||||
model is penalized for writing `60000` where the reference says
|
||||
`шестьдесят тысяч`. Thousands separators are joined before expansion, or
|
||||
`60 000` expands to `шестьдесят ноль`.
|
||||
|
||||
## Headline
|
||||
|
||||
| arm | WER | CER | exact | empty | RTF |
|
||||
|---|---|---|---|---|---|
|
||||
| cw2-turbo-intended | **10.4%** | 3.4% | 65.5% | 0 | 0.065 |
|
||||
| cw2-turbo-verbatim | 10.8% | **3.1%** | **66.5%** | 0 | 0.065 |
|
||||
| whisper-turbo | 11.8% | 4.1% | 64.0% | 0 | 0.031 |
|
||||
| cw2-large-intended | 12.3% | 3.8% | 63.5% | 0 | 0.107 |
|
||||
| whisper-small | 27.5% | 9.8% | 35.0% | 0 | 0.026 |
|
||||
|
||||
`whisper-small` is the floor, because `ggml-small.bin` is what mavsttd loads on
|
||||
homesrv today. CW2 turbo beats it by 17 points of WER and takes exact matches
|
||||
from 35.0% to 65.5%.
|
||||
|
||||
Two results are worth naming beyond the winner. CW2 turbo beats its own base
|
||||
model, whisper-large-v3-turbo, by 1.4 points. And it beats CW2 large by 1.9
|
||||
points, which inverts what the card implies by calling turbo a degraded draft.
|
||||
No arm returned an empty transcript.
|
||||
|
||||
## Intended and verbatim are closer than the mode names suggest
|
||||
|
||||
The two modes disagree on 70 of the 200 clips before normalization and on 29
|
||||
after it. So the raw difference is mostly casing and punctuation, which
|
||||
normalization removes and which Maven does not read either.
|
||||
|
||||
Verbatim scores worse on WER and better on CER and exact matches. The reason is
|
||||
script, not disfluency:
|
||||
|
||||
```text
|
||||
ref: футбольный матч челси брайтон
|
||||
int: Футбольный матч Chelsea-Брайтон.
|
||||
ver: Футбольный матч Челси Брайтон.
|
||||
```
|
||||
|
||||
Intended writes foreign entity names in Latin script and verbatim
|
||||
transliterates them. Golos references are Cyrillic throughout, so verbatim
|
||||
collects the exact matches. That is a property of this corpus rather than a
|
||||
quality difference.
|
||||
|
||||
**This corpus cannot settle the mode choice.** Golos crowd is clean short
|
||||
commands with almost no disfluency. The two modes have nothing to disagree
|
||||
about here. They separate on spontaneous speech with fillers, restarts and
|
||||
repairs, which is what the owner speaks. Intended stays the choice for the
|
||||
reason it was always the choice. Maven wants what was meant, not every stumble
|
||||
on the way there.
|
||||
|
||||
The Latin-script habit is the one finding here that touches routing. The
|
||||
routing heads were trained on Cyrillic utterances, so an entity name arriving
|
||||
in Latin script is out of distribution for them. Nothing measures that yet.
|
||||
|
||||
## The runtime is workpc, because whisper.cpp cannot load CW2
|
||||
|
||||
`num_languages()` in `deps/whisper.cpp/src/whisper.cpp` derives the language
|
||||
count from the vocabulary size:
|
||||
|
||||
```cpp
|
||||
return n_vocab - 51765 - (is_multilingual() ? 1 : 0);
|
||||
```
|
||||
|
||||
CW2 carries 31 extra tokens, so `n_vocab` is 51897 and this yields 131
|
||||
languages. The derived `dt` offset becomes 33 and shifts seven special token
|
||||
ids, including `token_beg` and `token_transcribe`. The architecture is
|
||||
otherwise byte-identical to whisper-large-v3-turbo, and the new tokens sit
|
||||
above every whisper special id.
|
||||
|
||||
So loading CW2 in whisper.cpp is a patch to a vendored dependency, not a port.
|
||||
It was not taken, because STT is moving to workpc anyway under V-486. CW2 turbo
|
||||
becomes the preferred remote and `ggml-small.bin` on homesrv stays the floor,
|
||||
which is the shape `modelSeam` already uses for routing and replies. The 27.5%
|
||||
floor is what a turn falls back to when the workstation is down, and this table
|
||||
is what that costs.
|
||||
|
||||
## License
|
||||
|
||||
Standard CW2 weights carry `nyra-health-non-commercial-research`. The Pro
|
||||
variants are commercial-license only. Maven is personal and self-hosted, so the
|
||||
standard weights are usable and the Pro ones are not free to take.
|
||||
|
||||
## What is not measured
|
||||
|
||||
Disfluent spontaneous speech, which is the whole reason to prefer Intended.
|
||||
Long-form audio beyond 13.5s. Far-field or noisy microphones. English, which
|
||||
Maven also speaks. The ONNX turbo export, which was never run, since the
|
||||
transformers path already meets the latency budget at RTF 0.065.
|
||||
@@ -37,6 +37,16 @@ type EmbedderConfig struct {
|
||||
ModelPath string `json:"model_path,omitempty"`
|
||||
TokenizerPath string `json:"tokenizer_path,omitempty"`
|
||||
LibPath string `json:"lib_path,omitempty"`
|
||||
|
||||
// HeadsPath — the routing heads graph, which is a fine-tuned COPY of the
|
||||
// model above with four linear heads on its pooled output (V-664). Empty
|
||||
// means no heads, and the cascade runs exactly as it did before they
|
||||
// existed. It shares LibPath and TokenizerPath, and router_heads.json is
|
||||
// read from the same directory.
|
||||
//
|
||||
// It must never be pointed at ModelPath. Memory recall depends on the
|
||||
// resident copy scoring what it scored, and the fine-tuned one does not.
|
||||
HeadsPath string `json:"heads_path,omitempty"`
|
||||
}
|
||||
|
||||
// WeatherConfig configures the weather provider for voice queries.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -35,12 +36,54 @@ type WorkstationConfig struct {
|
||||
// 0 ⇒ DefaultWorkstationTimeout. A big model on a LAN host is slower than
|
||||
// the resident one, and a request that overruns falls back to the floor.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
|
||||
// Stt — CrisperWhisper 2.0 on the same machine, a separate service on its
|
||||
// own port. Absent ⇒ every utterance goes to mavsttd, which is today.
|
||||
Stt *WorkstationSttConfig `json:"stt,omitempty"`
|
||||
}
|
||||
|
||||
// WorkstationSttConfig — speech-to-text on the workstation.
|
||||
//
|
||||
// It is a second service and not a second endpoint on mavgpud: whisper.cpp
|
||||
// cannot load CrisperWhisper 2.0 at all, because it derives its language count
|
||||
// from the vocabulary size and CW2's 51897 tokens shift seven special token
|
||||
// ids. So CW2 runs under transformers, and this block addresses it.
|
||||
//
|
||||
// Worth the trouble: CW2 turbo scores 10.4% WER in Russian against 27.5% for
|
||||
// the ggml-small.bin homesrv loads
|
||||
// (docs/evals/2026-08-09-crisperwhisper2-russian-wer.md).
|
||||
type WorkstationSttConfig struct {
|
||||
// URL — the transcribe endpoint, e.g.
|
||||
// "http://192.168.1.105:8081/transcribe". Empty ⇒ the block is normalised
|
||||
// to nil and mavsttd takes every turn.
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
// Health — the admission endpoint. Empty ⇒ the URL's origin + "/health".
|
||||
// It answers 503 while the card is held, and that is the signal.
|
||||
Health string `json:"health,omitempty"`
|
||||
|
||||
// Token — the bearer token the service checks. Audio is the most sensitive
|
||||
// thing that crosses this seam, so a LAN deployment should set one. Write
|
||||
// it as ${MAVEN_STT_TOKEN} and keep the value in deploy/telegram.env, the
|
||||
// way every other secret in this file is written.
|
||||
Token string `json:"token,omitempty"`
|
||||
|
||||
// Probe — how often admission is re-checked. 0 ⇒ DefaultWorkstationProbe.
|
||||
Probe Duration `json:"probe,omitempty"`
|
||||
|
||||
// Timeout — the per-request budget for one utterance. 0 ⇒
|
||||
// DefaultWorkstationSttTimeout. A request that overruns falls back to
|
||||
// mavsttd, which costs a worse transcript and not the turn.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
// Workstation defaults, applied in normaliseWorkstation.
|
||||
const (
|
||||
DefaultWorkstationProbe = 15 * time.Second
|
||||
DefaultWorkstationTimeout = 90 * time.Second
|
||||
// One utterance, not one completion. A voice turn waits on this, so the
|
||||
// budget is a few seconds and not a minute and a half.
|
||||
DefaultWorkstationSttTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// normaliseWorkstation applies the block's defaults. No address, no preferred
|
||||
@@ -63,4 +106,36 @@ func (c *Config) normaliseWorkstation() {
|
||||
if w.Timeout <= 0 {
|
||||
w.Timeout = Duration(DefaultWorkstationTimeout)
|
||||
}
|
||||
normaliseWorkstationStt(w)
|
||||
}
|
||||
|
||||
// normaliseWorkstationStt applies the speech-to-text block's defaults. No
|
||||
// address, no remote: mavsttd then takes every utterance, which is today.
|
||||
func normaliseWorkstationStt(w *WorkstationConfig) {
|
||||
if w.Stt != nil && strings.TrimSpace(w.Stt.URL) == "" {
|
||||
w.Stt = nil
|
||||
}
|
||||
if w.Stt == nil {
|
||||
return
|
||||
}
|
||||
s := w.Stt
|
||||
if strings.TrimSpace(s.Health) == "" {
|
||||
s.Health = healthOrigin(s.URL)
|
||||
}
|
||||
if s.Probe <= 0 {
|
||||
s.Probe = Duration(DefaultWorkstationProbe)
|
||||
}
|
||||
if s.Timeout <= 0 {
|
||||
s.Timeout = Duration(DefaultWorkstationSttTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// healthOrigin derives the admission endpoint from the transcribe endpoint.
|
||||
// The URL names a path, so appending to it would ask for /transcribe/health.
|
||||
func healthOrigin(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return strings.TrimRight(raw, "/") + "/health"
|
||||
}
|
||||
return u.Scheme + "://" + u.Host + "/health"
|
||||
}
|
||||
|
||||
@@ -47,6 +47,11 @@ type PendingQuestion struct {
|
||||
// charging it a retry is the V-554 shape. See CanResume for why it is
|
||||
// counted at all.
|
||||
Suspends int
|
||||
// Rides counts every turn this question has ridden out on the end of
|
||||
// someone else's reply, over the whole life of the request. Unlike Suspends
|
||||
// it is never reset and never re-based, which is the only property that
|
||||
// matters about it (V-663).
|
||||
Rides int
|
||||
}
|
||||
|
||||
// MaxSuspends — how many times one question may step aside and come back before
|
||||
@@ -63,10 +68,52 @@ type PendingQuestion struct {
|
||||
// is that he has moved on and has not said so.
|
||||
const MaxSuspends = 3
|
||||
|
||||
// MaxRides — how many turns one question may ride out on the end of an
|
||||
// unrelated reply, counted over its whole life (V-663).
|
||||
//
|
||||
// MaxSuspends did not move the measurement it was written for. Twenty-six of
|
||||
// 140 turns carried a tail before it landed and twenty-six carried one after.
|
||||
// Every bound on this question is rearmed by something ordinary:
|
||||
//
|
||||
// - The TTL is an inactivity timer, and both noteSuspended and reaskOrGiveUp
|
||||
// restart it, so it cannot arrive while he keeps talking.
|
||||
// - Suspends is zeroed by any turn that reads as an answer, which is where
|
||||
// "спасибо" and "привет" land. It resets before anything is known to have
|
||||
// been filled.
|
||||
// - askRemainingGap builds a fresh question for the second gap, so a reminder
|
||||
// with two gaps gets a new allowance halfway through.
|
||||
//
|
||||
// So Suspends only bites on four strictly consecutive side queries with nothing
|
||||
// chat-like between them, which is not the shape real conversation has. Rides is
|
||||
// the same idea with the resets taken out: set once, incremented, carried
|
||||
// across a re-park, and read by nothing that could lower it.
|
||||
//
|
||||
// The shape it is aimed at is measured, not imagined. In the 2026-08-08 run one
|
||||
// question about a reminder's day rode turns 7 to 13 and ended only because
|
||||
// turn 14 was a new request. Three asides, then two turns that read as failed
|
||||
// answers, then two more asides. The asides spend no attempt and the answers
|
||||
// reset Suspends, so the two bounds take turns being rearmed by the other's
|
||||
// traffic.
|
||||
//
|
||||
// Four, not three. It has to be looser than MaxSuspends or that bound is dead
|
||||
// code, because Rides is never lower than Suspends and would always fire first.
|
||||
//
|
||||
// Do not read this as a fix for the whole ride. It ends the measured one a turn
|
||||
// early and no more. Most of that ride's length is attempts, spent by turns
|
||||
// like "спасибо" and "привет" being read as failed answers to a question about
|
||||
// a day. That is a defect in classifyTurnRole and not in any bound here.
|
||||
const MaxRides = 4
|
||||
|
||||
// CanResume reports whether this question may step aside once more. False ⇒ the
|
||||
// caller lets the request go and says so; it must never simply stop resuming,
|
||||
// because a question dropped in silence reads as one that was answered.
|
||||
func (q *PendingQuestion) CanResume() bool { return q.Suspends < MaxSuspends }
|
||||
//
|
||||
// Two bounds, and they answer different questions. Suspends asks whether he has
|
||||
// walked away from this exchange in the last few turns. Rides asks whether this
|
||||
// question has been riding long enough that the answer is no regardless.
|
||||
func (q *PendingQuestion) CanResume() bool {
|
||||
return q.Suspends < MaxSuspends && q.Rides < MaxRides
|
||||
}
|
||||
|
||||
// Action reads the parked question as the typed action it is assembling
|
||||
// (pending.go). Derived rather than stored: the question's fields stay the one
|
||||
|
||||
@@ -119,6 +119,11 @@ func PartsOfDay() []string { return words("parts_of_day") }
|
||||
// ReminderVerbs returns the imperatives that open a reminder.
|
||||
func ReminderVerbs() []string { return words("reminder_verbs") }
|
||||
|
||||
// Pleasantries returns the whole utterances that greet, thank or say goodbye.
|
||||
// Whole utterances and not tokens: see the set's own note for why the tokens
|
||||
// are unsafe alone.
|
||||
func Pleasantries() []string { return words("pleasantries") }
|
||||
|
||||
// TaskDoneWords returns the words that finish a task, and TaskDropWords the
|
||||
// words that abandon one. Two sets rather than one with a value, because the
|
||||
// store records which of the two happened and the caller has to say so.
|
||||
|
||||
@@ -176,6 +176,19 @@
|
||||
"morning", "afternoon", "evening", "night"
|
||||
]
|
||||
},
|
||||
"pleasantries": {
|
||||
"note": "Whole utterances that greet, thank or say goodbye. They ask for nothing and answer nothing, so a parked question must neither consume them as a failed answer nor be dropped by them (V-663). Matched as WHOLE utterances and never as tokens, because the tokens are not safe alone: \"вечер\" answers \"это утра или вечера?\" and \"нет\" answers a confirm. Anything that could fill a slot stays out. The control words (\"стоп\", \"отмена\") stay out too, because isCancel already owns them and they mean something stronger.",
|
||||
"words": [
|
||||
"привет", "приветик", "здравствуй", "здравствуйте",
|
||||
"доброе утро", "добрый день", "добрый вечер",
|
||||
"пока", "прощай", "до свидания", "спокойной ночи",
|
||||
"спасибо", "спасибо тебе", "большое спасибо", "благодарю",
|
||||
"извини", "извините", "прости", "простите",
|
||||
"hi", "hello", "hey", "bye", "goodbye",
|
||||
"good morning", "good evening", "good night",
|
||||
"thanks", "thank you", "thanks a lot", "sorry"
|
||||
]
|
||||
},
|
||||
"reminder_verbs": {
|
||||
"note": "The imperatives that mean \"remind me\", in the forms he speaks. The same kind of set as capture_verbs and decided the same way: it is her vocabulary, not a discovery about Russian (Vikunja #530). The alarm verbs joined them in V-627. \"разбуди меня в 6:30\" is a reminder that fires at the hour he gets up, and the set knew no form of it, so an alarm reached IntentReminder only by resembling one to the embedder.",
|
||||
"words": [
|
||||
|
||||
@@ -14,12 +14,15 @@ import (
|
||||
"github.com/kami/maven/internal/decision"
|
||||
)
|
||||
|
||||
// The two routing engines, named as claimants. They are one stage and not two,
|
||||
// because only one of them ever runs: the classifier is reached when the model
|
||||
// is absent or errored, never alongside it.
|
||||
// The three routing engines, named as claimants. The model and the classifier
|
||||
// are one stage and not two, because only one of them ever runs: the classifier
|
||||
// is reached when the model is absent or errored, never alongside it. The heads
|
||||
// run before both and decline on low confidence, so they can appear beside
|
||||
// either one in a record.
|
||||
const (
|
||||
claimantLLM = "llm-router"
|
||||
claimantClassifier = "classifier"
|
||||
claimantHeads = "routing-heads"
|
||||
)
|
||||
|
||||
// thinReason names which arm of gateLLMDecision cut the confidence. The gate
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package router
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEmbedderIDFromModelPath(t *testing.T) {
|
||||
got := modelIDFromPath("/opt/maven/models/embedder/multilingual-e5-small.onnx")
|
||||
if got != "multilingual-e5-small@384" {
|
||||
if got != "multilingual-e5-small@384/tok2" {
|
||||
t.Fatalf("modelIDFromPath = %q", got)
|
||||
}
|
||||
// A different model file must produce a different id, even at 384 dim.
|
||||
@@ -12,6 +15,13 @@ func TestEmbedderIDFromModelPath(t *testing.T) {
|
||||
if old == got {
|
||||
t.Fatal("two different models share one id")
|
||||
}
|
||||
// The tokenizer is half of what makes a vector, and it changes under a
|
||||
// model file whose name never moves (V-664). An id that ignored it would
|
||||
// leave stored passages in one space and every new query in another, with
|
||||
// nothing to trigger the re-embed.
|
||||
if !strings.Contains(got, "/tok") {
|
||||
t.Fatalf("id %q does not name the tokenizer revision", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbedderIDIncludesDim(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// TestONNXRoutingHeads — the cascade with the routing heads wired, which is
|
||||
// what V-664 deploys. Opt-in via MAVEN_ONNX_LIB, same as TestONNXBaseline, and
|
||||
// one TestONNX* per process.
|
||||
//
|
||||
// The comparison worth reading is against TestONNXBaseline, which is the same
|
||||
// cascade with the same grammars and the same classifier floor and no heads.
|
||||
// Only the middle arm varies.
|
||||
//
|
||||
// It also checks the Go unigram tokenizer against the Python one, because the
|
||||
// heads were trained through transformers and are read through a hand-written
|
||||
// tokenizer. A mismatch shows up here as a score below what Python measured on
|
||||
// the same weights, and nowhere else.
|
||||
func TestONNXRoutingHeads(t *testing.T) {
|
||||
lib := os.Getenv("MAVEN_ONNX_LIB")
|
||||
if lib == "" {
|
||||
t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing")
|
||||
}
|
||||
// Absolute, because onnxruntime resolves a graph's external weights file
|
||||
// against the model path it was given, and a relative one lands in the
|
||||
// test's working directory.
|
||||
root, err := filepath.Abs("../../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
model := filepath.Join(root, "models/embedder/multilingual-e5-small/model_quantized.onnx")
|
||||
tok := filepath.Join(root, "models/embedder/multilingual-e5-small/tokenizer.json")
|
||||
heads := filepath.Join(root, "models/embedder/router-heads/router_heads.onnx")
|
||||
for _, p := range []string{lib, model, tok, heads} {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Skipf("missing %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
emb, err2 := router.NewONNXEmbedder(model, tok, lib)
|
||||
if err2 != nil {
|
||||
t.Skipf("onnx embedder unavailable: %v", err2)
|
||||
}
|
||||
err = nil
|
||||
defer emb.Close()
|
||||
|
||||
h, err := router.NewRouterHeads(heads, tok)
|
||||
if err != nil {
|
||||
t.Skipf("routing heads unavailable: %v", err)
|
||||
}
|
||||
defer h.Close()
|
||||
|
||||
f, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
rep, err := Score(context.Background(), "heads+classifier", withHeads(t, emb, h), f)
|
||||
if err != nil {
|
||||
t.Fatalf("Score: %v", err)
|
||||
}
|
||||
t.Log("\n" + rep.String() + rep.Failures())
|
||||
}
|
||||
|
||||
// withHeads mirrors newBaselineRouter and adds the one arm under test. It is a
|
||||
// separate function rather than a parameter so the baseline's signature stays
|
||||
// the shape every other test calls it with.
|
||||
func withHeads(t *testing.T, emb router.Embedder, h *router.RouterHeads) *router.Router {
|
||||
t.Helper()
|
||||
acts := router.DefaultActMatcher{Fns: actFns}
|
||||
return router.New(router.Config{
|
||||
Grammars: baselineGrammars(acts),
|
||||
Classifier: newBaselineClassifier(t, emb),
|
||||
Extractor: router.Extractor{
|
||||
Time: router.StubDateTimeParser{},
|
||||
Acts: acts,
|
||||
Facts: router.DefaultFactParser{},
|
||||
},
|
||||
Threshold: config.DefaultRouterThreshold,
|
||||
Heads: h,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
ort "github.com/yalue/onnxruntime_go"
|
||||
)
|
||||
|
||||
// The routing heads (V-546, V-661, V-664). Four linear heads over one masked
|
||||
// mean pool of a fine-tuned copy of multilingual-e5-small: intent,
|
||||
// destination, BIO slot tags and clarify. Trained on workpc, exported to ONNX,
|
||||
// and read here.
|
||||
//
|
||||
// Why this is not the classifier. The classifier compares one utterance to
|
||||
// frozen seed phrases by cosine. A head is a softmax over the label set, so it
|
||||
// cannot name a value that does not exist, and its max is a calibratable
|
||||
// confidence where Confidence: 1.0 was a hardcode.
|
||||
//
|
||||
// Why it is not the resident model either. It answers in single-digit
|
||||
// milliseconds against the model's p50 of 1.19s, and it names a destination
|
||||
// the classifier arm never names at all.
|
||||
//
|
||||
// The body is a COPY of the embedder weights, fine-tuned. It must never
|
||||
// replace models/embedder/multilingual-e5-small — memory recall depends on
|
||||
// that file scoring what it scored.
|
||||
//
|
||||
// The slot head is exported and deliberately not read. Slots already come from
|
||||
// the stage-2 extractor, and mapping BIO tags back to text needs character
|
||||
// offsets the unigram tokenizer does not keep. Reading it is separate work.
|
||||
const (
|
||||
// headsSeq — the sequence length the heads were trained at. Padding is
|
||||
// masked out of both attention and the pool, so this changes nothing but
|
||||
// truncation, and truncation is what training did at 64.
|
||||
headsSeq = 64
|
||||
|
||||
// headsThreshold — max softmax over the intent head, below which the heads
|
||||
// decline and the cascade carries on to the resident model.
|
||||
//
|
||||
// 0.6 is the knee measured on the 88-case intent fixture
|
||||
// (docs/evals/2026-08-08-routing-heads-in-go.md). It keeps 81 of 88 cases
|
||||
// at 97.5% accuracy. Every higher value up to 0.9 drops right answers and
|
||||
// keeps the same two wrong ones, so it buys nothing.
|
||||
headsThreshold = 0.6
|
||||
)
|
||||
|
||||
// RouterHeads runs the exported graph. Nil is a working value everywhere: a
|
||||
// deployment with no weights file routes exactly as it did before this
|
||||
// existed.
|
||||
type RouterHeads struct {
|
||||
tokenizer *unigramTokenizer
|
||||
session *ort.DynamicSession[int64, float32]
|
||||
intents []Intent
|
||||
sources []Source
|
||||
threshold float64
|
||||
}
|
||||
|
||||
// headsMeta — router_heads.json, written beside the weights by the exporter.
|
||||
// The label order is the head's output order and cannot be inferred from Go.
|
||||
type headsMeta struct {
|
||||
Intents []string `json:"intents"`
|
||||
Sources []string `json:"sources"`
|
||||
Prefix string `json:"prefix"`
|
||||
}
|
||||
|
||||
// NewRouterHeads loads the graph and its label order. modelPath points at the
|
||||
// .onnx; the external weights and router_heads.json sit beside it.
|
||||
//
|
||||
// It assumes the ONNX environment is already initialised, because the embedder
|
||||
// does that at startup and the runtime allows it once.
|
||||
func NewRouterHeads(modelPath, tokenizerPath string) (*RouterHeads, error) {
|
||||
metaPath := filepath.Join(filepath.Dir(modelPath), "router_heads.json")
|
||||
raw, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("heads: read %s: %w", metaPath, err)
|
||||
}
|
||||
var meta headsMeta
|
||||
if err := json.Unmarshal(raw, &meta); err != nil {
|
||||
return nil, fmt.Errorf("heads: parse %s: %w", metaPath, err)
|
||||
}
|
||||
if meta.Prefix != queryPrefix {
|
||||
return nil, fmt.Errorf("heads: trained with prefix %q, this build uses %q",
|
||||
meta.Prefix, queryPrefix)
|
||||
}
|
||||
|
||||
intents := make([]Intent, len(meta.Intents))
|
||||
for i, s := range meta.Intents {
|
||||
intents[i] = Intent(s)
|
||||
}
|
||||
sources := make([]Source, len(meta.Sources))
|
||||
for i, s := range meta.Sources {
|
||||
// SourceUnknown is not in Sources, because it is the absence of a
|
||||
// choice. It is a class the head can emit, and the one it should emit
|
||||
// often, so it is allowed here and nowhere else.
|
||||
if s != string(SourceUnknown) && !ValidSource(Source(s)) {
|
||||
return nil, fmt.Errorf("heads: unknown destination %q in %s", s, metaPath)
|
||||
}
|
||||
sources[i] = Source(s)
|
||||
}
|
||||
|
||||
tok, err := newUnigramTokenizer(tokenizerPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("heads: tokenizer: %w", err)
|
||||
}
|
||||
session, err := ort.NewDynamicSession[int64, float32](
|
||||
modelPath,
|
||||
[]string{"input_ids", "attention_mask"},
|
||||
[]string{"intent", "source", "slots", "clarify"},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("heads: create session: %w", err)
|
||||
}
|
||||
|
||||
return &RouterHeads{
|
||||
tokenizer: tok,
|
||||
session: session,
|
||||
intents: intents,
|
||||
sources: sources,
|
||||
threshold: headsThreshold,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *RouterHeads) Close() error {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
h.session.Destroy()
|
||||
return nil
|
||||
}
|
||||
|
||||
// headsResult — one forward pass, read back.
|
||||
type headsResult struct {
|
||||
Intent Intent
|
||||
Source Source
|
||||
Confidence float64
|
||||
Clarify bool
|
||||
}
|
||||
|
||||
// Route runs the heads and reports whether they are confident enough to answer.
|
||||
// A false second return is a decline, not an error: the cascade goes on to the
|
||||
// resident model, which is what happens today.
|
||||
func (h *RouterHeads) Route(ctx context.Context, utterance string) (headsResult, bool, error) {
|
||||
if h == nil {
|
||||
return headsResult{}, false, nil
|
||||
}
|
||||
ids, mask, _ := h.tokenizer.Encode(queryPrefix + utterance)
|
||||
ids, mask = ids[:headsSeq], mask[:headsSeq]
|
||||
// The tokenizer pads and truncates to its own length, which is longer than
|
||||
// this one. Cutting the tail can cut the separator with it, so put it back.
|
||||
if mask[headsSeq-1] == 1 {
|
||||
ids[headsSeq-1] = sepTokenID
|
||||
}
|
||||
|
||||
shape := ort.NewShape(1, headsSeq)
|
||||
idsT, err := ort.NewTensor(shape, ids)
|
||||
if err != nil {
|
||||
return headsResult{}, false, fmt.Errorf("heads: ids tensor: %w", err)
|
||||
}
|
||||
defer idsT.Destroy()
|
||||
maskT, err := ort.NewTensor(shape, mask)
|
||||
if err != nil {
|
||||
return headsResult{}, false, fmt.Errorf("heads: mask tensor: %w", err)
|
||||
}
|
||||
defer maskT.Destroy()
|
||||
|
||||
intentT, err := ort.NewEmptyTensor[float32](ort.NewShape(1, int64(len(h.intents))))
|
||||
if err != nil {
|
||||
return headsResult{}, false, fmt.Errorf("heads: intent tensor: %w", err)
|
||||
}
|
||||
defer intentT.Destroy()
|
||||
sourceT, err := ort.NewEmptyTensor[float32](ort.NewShape(1, int64(len(h.sources))))
|
||||
if err != nil {
|
||||
return headsResult{}, false, fmt.Errorf("heads: source tensor: %w", err)
|
||||
}
|
||||
defer sourceT.Destroy()
|
||||
slotsT, err := ort.NewEmptyTensor[float32](ort.NewShape(1, headsSeq, int64(numBIOTags)))
|
||||
if err != nil {
|
||||
return headsResult{}, false, fmt.Errorf("heads: slots tensor: %w", err)
|
||||
}
|
||||
defer slotsT.Destroy()
|
||||
clarifyT, err := ort.NewEmptyTensor[float32](ort.NewShape(1, 2))
|
||||
if err != nil {
|
||||
return headsResult{}, false, fmt.Errorf("heads: clarify tensor: %w", err)
|
||||
}
|
||||
defer clarifyT.Destroy()
|
||||
|
||||
if err := h.session.Run(
|
||||
[]*ort.Tensor[int64]{idsT, maskT},
|
||||
[]*ort.Tensor[float32]{intentT, sourceT, slotsT, clarifyT},
|
||||
); err != nil {
|
||||
return headsResult{}, false, fmt.Errorf("heads: run: %w", err)
|
||||
}
|
||||
|
||||
// The graph applies its own softmax, so these are probabilities and the max
|
||||
// is the same number the eval calibrated the threshold against.
|
||||
i, conf := argmax(intentT.GetData())
|
||||
res := headsResult{
|
||||
Intent: h.intents[i],
|
||||
Confidence: conf,
|
||||
}
|
||||
cl := clarifyT.GetData()
|
||||
res.Clarify = len(cl) == 2 && cl[1] > cl[0]
|
||||
|
||||
// The destination head is trained on query rows and is meaningless on any
|
||||
// other intent, the same way queryWalk is never reached by one.
|
||||
if res.Intent == IntentQuery {
|
||||
s, _ := argmax(sourceT.GetData())
|
||||
res.Source = h.sources[s]
|
||||
}
|
||||
|
||||
// The clarify head decides on its own, and it decides first. It answers a
|
||||
// different question from the intent head — not which intent, but whether
|
||||
// there is enough here to act on at all — so a low intent confidence is no
|
||||
// reason to discard it. It is usually the same turns: "вода" reads as
|
||||
// intent act at 0.23 and clarify at 0.98, and letting the intent threshold
|
||||
// bury that hands the turn to the classifier, which routes it confidently
|
||||
// and never asks.
|
||||
if res.Clarify {
|
||||
return res, true, nil
|
||||
}
|
||||
if conf < h.threshold {
|
||||
return res, false, nil
|
||||
}
|
||||
return res, true, nil
|
||||
}
|
||||
|
||||
// numBIOTags — O plus B- and I- for each of Maven's five slots. The head is not
|
||||
// read, but the graph writes it and the output tensor has to be the right size.
|
||||
const numBIOTags = 11
|
||||
|
||||
func argmax(v []float32) (int, float64) {
|
||||
best, bestV := 0, math.Inf(-1)
|
||||
for i, x := range v {
|
||||
if float64(x) > bestV {
|
||||
best, bestV = i, float64(x)
|
||||
}
|
||||
}
|
||||
return best, bestV
|
||||
}
|
||||
@@ -66,12 +66,19 @@ func NewONNXEmbedder(modelPath, tokenizerPath, libPath string) (*onnxEmbedder, e
|
||||
func (e *onnxEmbedder) Dim() int { return embedDim }
|
||||
|
||||
// ID names the loaded model for the DB marker (Vikunja #378): the model file's
|
||||
// own name plus the dimension, so pointing the config at another model changes
|
||||
// the string on its own.
|
||||
// own name, the dimension, and the tokenizer revision, so pointing the config
|
||||
// at another model changes the string on its own.
|
||||
func (e *onnxEmbedder) ID() string { return e.id }
|
||||
|
||||
// tokenizerRev — bumped whenever the tokenizer changes what it emits for the
|
||||
// same text, because that changes every vector while the model file's name
|
||||
// stays put. Rev 2 is the fix for the reversed word pieces (V-664): stored
|
||||
// passages embedded under rev 1 no longer sit in the same space as a query
|
||||
// embedded now, and ReembedAll rewrites them because this string moved.
|
||||
const tokenizerRev = 2
|
||||
|
||||
// modelIDFromPath turns /opt/.../multilingual-e5-small.onnx into
|
||||
// "multilingual-e5-small@384".
|
||||
// "multilingual-e5-small@384/tok2".
|
||||
func modelIDFromPath(modelPath string) string {
|
||||
name := modelPath
|
||||
if i := strings.LastIndexAny(name, "/\\"); i >= 0 {
|
||||
@@ -81,7 +88,7 @@ func modelIDFromPath(modelPath string) string {
|
||||
if name == "" {
|
||||
name = "onnx"
|
||||
}
|
||||
return fmt.Sprintf("%s@%d", name, embedDim)
|
||||
return fmt.Sprintf("%s@%d/tok%d", name, embedDim, tokenizerRev)
|
||||
}
|
||||
|
||||
// Embed treats the text as a query. The classifier compares one short
|
||||
@@ -339,14 +346,17 @@ func (t *unigramTokenizer) encodeWord(word string) []int64 {
|
||||
}
|
||||
}
|
||||
|
||||
// Backtracking walks the word from its end, and prepending each piece puts
|
||||
// it back in reading order. There used to be a second reverse after this
|
||||
// loop, which undid it: every multi-piece word came out backwards, and
|
||||
// "query: вода" tokenized to [0 12 1294 41 12489 2] where the reference
|
||||
// tokenizer gives [0 41 1294 12 12489 2] (V-664). A transformer reads
|
||||
// position, so the pieces of a long Russian word were being read in the
|
||||
// wrong order on every turn.
|
||||
var result []int64
|
||||
for i := n; i > 0; i = prev[i] {
|
||||
result = append([]int64{bestID[i]}, result...)
|
||||
}
|
||||
// Reverse
|
||||
for l, r := 0, len(result)-1; l < r; l, r = l+1, r-1 {
|
||||
result[l], result[r] = result[r], result[l]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,12 @@ type Config struct {
|
||||
// error/parse failure, falls through to the classifier (never fails the
|
||||
// turn on the model).
|
||||
LLM *LLMRouter
|
||||
// Heads — optional routing heads over the fine-tuned embedder copy. When
|
||||
// set, Route consults them after stage 0 and before the LLM router. They
|
||||
// decline below their own confidence threshold, so a low-confidence turn
|
||||
// reaches the model exactly as it does today. Nil is the shipped-before
|
||||
// behaviour and costs nothing.
|
||||
Heads *RouterHeads
|
||||
}
|
||||
|
||||
// Router — the deterministic cascade. Route never guesses: stage 0 wins
|
||||
@@ -42,6 +48,7 @@ type Router struct {
|
||||
extractor Extractor
|
||||
threshold float64
|
||||
llm *LLMRouter
|
||||
heads *RouterHeads
|
||||
}
|
||||
|
||||
func New(cfg Config) *Router {
|
||||
@@ -51,6 +58,7 @@ func New(cfg Config) *Router {
|
||||
extractor: cfg.Extractor,
|
||||
threshold: cfg.Threshold,
|
||||
llm: cfg.LLM,
|
||||
heads: cfg.Heads,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +106,60 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
|
||||
}
|
||||
r.noteGrammarOutcomes(ctx, len(r.grammars), declinedBuild, "", "")
|
||||
|
||||
// stage 0b — routing heads (when wired). A softmax over the label set, so
|
||||
// it cannot name an intent or a destination that does not exist, and its
|
||||
// max is a real confidence. It runs before the model because it is three
|
||||
// orders of magnitude faster and scores better on both halves of the route.
|
||||
//
|
||||
// It declines below its threshold rather than clarifying. A declined turn
|
||||
// carries on to the model and then the classifier, which is what a box with
|
||||
// no weights file does on every turn.
|
||||
if r.heads != nil {
|
||||
res, ok, err := r.heads.Route(ctx, utterance)
|
||||
switch {
|
||||
case err != nil:
|
||||
log.Printf("router: heads fell through to the rest of the cascade: %v", err)
|
||||
decision.Note(ctx, decision.Claim{
|
||||
Stage: decision.StageRoute, Claimant: claimantHeads,
|
||||
Outcome: decision.Declined, Reason: "error: " + err.Error(),
|
||||
})
|
||||
case !ok:
|
||||
decision.Note(ctx, decision.Scored(decision.StageRoute, claimantHeads,
|
||||
string(res.Intent), res.Confidence, decision.Declined,
|
||||
"below the heads confidence threshold"))
|
||||
default:
|
||||
d := Decision{
|
||||
Utterance: utterance,
|
||||
Stage: 2,
|
||||
Intent: res.Intent,
|
||||
Confidence: res.Confidence,
|
||||
Source: res.Source,
|
||||
Clarify: res.Clarify,
|
||||
}
|
||||
r.fillSlots(ctx, &d, now)
|
||||
decision.Note(ctx, decision.Claim{
|
||||
Stage: decision.StageRoute, Claimant: claimantLLM,
|
||||
Outcome: decision.NeverAsked, Reason: "the routing heads answered",
|
||||
})
|
||||
decision.Note(ctx, decision.Claim{
|
||||
Stage: decision.StageRoute, Claimant: claimantClassifier,
|
||||
Outcome: decision.NeverAsked, Reason: "the routing heads answered",
|
||||
})
|
||||
outcome, reason := decision.Won, ""
|
||||
if d.Clarify {
|
||||
outcome, reason = decision.Thinned, "the clarify head says there is too little here to act on"
|
||||
}
|
||||
decision.Note(ctx, decision.Scored(decision.StageRoute, claimantHeads,
|
||||
string(d.Intent), d.Confidence, outcome, reason))
|
||||
return d, nil
|
||||
}
|
||||
} else {
|
||||
decision.Note(ctx, decision.Claim{
|
||||
Stage: decision.StageRoute, Claimant: claimantHeads,
|
||||
Outcome: decision.NeverAsked, Reason: "no routing heads are wired",
|
||||
})
|
||||
}
|
||||
|
||||
// stage 1a — LLM router (when wired). It reasons over the utterance instead
|
||||
// of nearest-centroid guessing. On any error/parse-fail, fall through to the
|
||||
// classifier cascade (never fail the turn on the model).
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package stt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
)
|
||||
|
||||
// HTTPTranscriber — speech-to-text on another host, over HTTP.
|
||||
//
|
||||
// mavsttd is whisper.cpp linked into a Go daemon and reached over a unix
|
||||
// socket. CrisperWhisper 2.0 cannot be reached that way: whisper.cpp derives
|
||||
// its language count from the vocabulary size, and CW2's 51897 tokens shift
|
||||
// seven special token ids. It runs under transformers instead, as a service
|
||||
// beside the model on workpc. See docs/evals/2026-08-09-crisperwhisper2-russian-wer.md.
|
||||
//
|
||||
// So this is the second transport for the same seam, not a second seam. The
|
||||
// caller still sees stt.Transcriber and one method.
|
||||
type HTTPTranscriber struct {
|
||||
url string
|
||||
token string
|
||||
lang string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewHTTPTranscriber builds the remote client. token may be empty for a
|
||||
// service on a trusted socket, but audio is the most sensitive thing that
|
||||
// crosses this seam, so a LAN deployment should always set one.
|
||||
func NewHTTPTranscriber(url, token, lang string, timeout time.Duration) *HTTPTranscriber {
|
||||
return &HTTPTranscriber{
|
||||
url: url,
|
||||
token: token,
|
||||
lang: lang,
|
||||
http: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
// ErrFormat — the audio is not the one canonical shape. Refused at the seam
|
||||
// rather than sent to a model that expects something else.
|
||||
var ErrFormat = errors.New("stt: audio is not 16kHz mono pcm_s16le")
|
||||
|
||||
type httpTranscript struct {
|
||||
Text string `json:"text"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
// Transcribe posts the raw PCM and reads back the text.
|
||||
//
|
||||
// The body is the PCM bytes themselves rather than JSON. A minute of 16kHz
|
||||
// mono is under 2MB raw and about 2.6MB base64, and the format is fixed by
|
||||
// audio.PCM16kMono, so a header carries it more cheaply than an envelope.
|
||||
func (t *HTTPTranscriber) Transcribe(ctx context.Context, a audio.Audio) (string, float64, error) {
|
||||
if !a.Format.IsValid() {
|
||||
return "", 0, ErrFormat
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.url, bytes.NewReader(a.Bytes))
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("stt: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.Header.Set("X-Sample-Rate", strconv.Itoa(a.Format.SampleRate))
|
||||
req.Header.Set("X-Channels", strconv.Itoa(a.Format.Channels))
|
||||
req.Header.Set("X-Sample-Bits", strconv.Itoa(a.Format.SampleBits))
|
||||
req.Header.Set("X-Language", t.lang)
|
||||
if t.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+t.token)
|
||||
}
|
||||
|
||||
resp, err := t.http.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("stt: post audio: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", 0, fmt.Errorf("stt: remote returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var out httpTranscript
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return "", 0, fmt.Errorf("stt: decode transcript: %w", err)
|
||||
}
|
||||
return out.Text, out.Confidence, nil
|
||||
}
|
||||
|
||||
var _ Transcriber = (*HTTPTranscriber)(nil)
|
||||
@@ -0,0 +1,89 @@
|
||||
package stt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
)
|
||||
|
||||
func TestHTTPTranscriberSendsRawPCM(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotBody []byte
|
||||
var gotHeader http.Header
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotBody, _ = io.ReadAll(r.Body)
|
||||
gotHeader = r.Header.Clone()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"text":"привет","confidence":0.82}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
a := audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("pcm-bytes")}
|
||||
tr := NewHTTPTranscriber(srv.URL, "s3cret", "ru", 2*time.Second)
|
||||
text, conf, err := tr.Transcribe(context.Background(), a)
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if text != "привет" || conf != 0.82 {
|
||||
t.Fatalf("got %q %v", text, conf)
|
||||
}
|
||||
if string(gotBody) != "pcm-bytes" {
|
||||
t.Fatalf("body should be the PCM itself, got %q", gotBody)
|
||||
}
|
||||
if got := gotHeader.Get("X-Sample-Rate"); got != strconv.Itoa(audio.PCM16kMono.SampleRate) {
|
||||
t.Fatalf("X-Sample-Rate = %q", got)
|
||||
}
|
||||
if got := gotHeader.Get("X-Language"); got != "ru" {
|
||||
t.Fatalf("X-Language = %q", got)
|
||||
}
|
||||
// Audio is the most sensitive thing crossing this seam.
|
||||
if got := gotHeader.Get("Authorization"); got != "Bearer s3cret" {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPTranscriberOmitsEmptyToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
var auth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth = r.Header.Get("Authorization")
|
||||
_, _ = io.WriteString(w, `{"text":"x"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
a := audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("x")}
|
||||
if _, _, err := NewHTTPTranscriber(srv.URL, "", "ru", time.Second).Transcribe(context.Background(), a); err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if auth != "" {
|
||||
t.Fatalf("Authorization should be absent, got %q", auth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPTranscriberRefusesWrongFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := audio.Audio{Format: audio.Format{SampleRate: 44100, Channels: 2, SampleBits: 16, Encoding: "pcm_s16le"}}
|
||||
_, _, err := NewHTTPTranscriber("http://example.invalid", "", "ru", time.Second).Transcribe(context.Background(), a)
|
||||
if !errors.Is(err, ErrFormat) {
|
||||
t.Fatalf("want ErrFormat, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPTranscriberErrorsOnBadStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
a := audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("x")}
|
||||
_, _, err := NewHTTPTranscriber(srv.URL, "", "ru", time.Second).Transcribe(context.Background(), a)
|
||||
if err == nil {
|
||||
t.Fatal("a 401 must be an error, so the Pair falls back")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package stt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
)
|
||||
|
||||
// Pair — a preferred transcriber on the workstation, with mavsttd as the floor.
|
||||
//
|
||||
// Same arrangement as llm.Pair and for the same reason. The microphone is at
|
||||
// workpc, the card there has 16GB, and CrisperWhisper 2.0 turbo scores 10.4%
|
||||
// WER in Russian against 27.5% for the ggml-small.bin homesrv loads
|
||||
// (docs/evals/2026-08-09-crisperwhisper2-russian-wer.md). The workstation is
|
||||
// never assumed up: it sleeps, and the card is often held by a training run.
|
||||
//
|
||||
// Speech-to-text has only the silent half of the degradation rule. A worse
|
||||
// transcript is still a turn, and there is nothing to name a gap about, so
|
||||
// Transcribe always falls back. That is the whole difference from llm.Pair,
|
||||
// which also carries CompleteRemote for callers that must refuse instead.
|
||||
type Pair struct {
|
||||
remote Transcriber
|
||||
floor Transcriber
|
||||
|
||||
// up — the cached admission answer, written only by the prober and read by
|
||||
// every turn. A voice turn must never wait on a machine that may be asleep.
|
||||
up atomic.Bool
|
||||
|
||||
health string
|
||||
interval time.Duration
|
||||
http *http.Client
|
||||
stop chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
const (
|
||||
probeTimeout = 2 * time.Second
|
||||
defaultProbeInterval = 15 * time.Second
|
||||
)
|
||||
|
||||
// ErrNoFloor — a Pair was built with no local transcriber to fall back to. A
|
||||
// configuration mistake: the floor is what makes the remote optional.
|
||||
var ErrNoFloor = errors.New("stt: no floor transcriber")
|
||||
|
||||
// NewPair builds the two-transcriber arrangement. remote may be nil, which is
|
||||
// the unconfigured deploy: every turn goes to the floor and nothing probes.
|
||||
func NewPair(remote, floor Transcriber, health string, interval time.Duration) *Pair {
|
||||
if interval <= 0 {
|
||||
// The config normalises this, so a zero here is a caller that built the
|
||||
// Pair directly. Panicking in a ticker is the wrong way to say so.
|
||||
interval = defaultProbeInterval
|
||||
}
|
||||
return &Pair{
|
||||
remote: remote,
|
||||
floor: floor,
|
||||
health: health,
|
||||
interval: interval,
|
||||
http: &http.Client{Timeout: probeTimeout},
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins probing. The first probe runs before the first tick, so a
|
||||
// workstation that is already up serves the first utterance rather than the
|
||||
// second. Safe with a nil remote.
|
||||
func (p *Pair) Start(ctx context.Context) {
|
||||
if p.remote == nil || p.health == "" {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
p.probe(ctx)
|
||||
t := time.NewTicker(p.interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-p.stop:
|
||||
return
|
||||
case <-t.C:
|
||||
p.probe(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop ends the prober. Idempotent and safe from two goroutines.
|
||||
func (p *Pair) Stop() {
|
||||
p.stopOnce.Do(func() { close(p.stop) })
|
||||
}
|
||||
|
||||
// Available reports whether the workstation will transcribe right now.
|
||||
func (p *Pair) Available() bool {
|
||||
return p.remote != nil && p.up.Load()
|
||||
}
|
||||
|
||||
func (p *Pair) probe(ctx context.Context) {
|
||||
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.health, nil)
|
||||
if err != nil {
|
||||
p.set(false)
|
||||
return
|
||||
}
|
||||
resp, err := p.http.Do(req)
|
||||
if err != nil {
|
||||
p.set(false)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
p.set(resp.StatusCode == http.StatusOK)
|
||||
}
|
||||
|
||||
// set records the admission answer and logs only transitions. A machine that
|
||||
// sleeps nightly would otherwise write one line per interval forever.
|
||||
func (p *Pair) set(up bool) {
|
||||
if p.up.Swap(up) == up {
|
||||
return
|
||||
}
|
||||
if up {
|
||||
log.Printf("stt: workstation transcriber available at %s", p.health)
|
||||
} else {
|
||||
log.Print("stt: workstation transcriber unavailable, falling back to mavsttd")
|
||||
}
|
||||
}
|
||||
|
||||
// Transcribe sends the audio to the workstation when it will take work, and to
|
||||
// mavsttd otherwise. A remote that fails mid-request falls back too, because
|
||||
// the admission answer is a cache and can be one interval out of date.
|
||||
//
|
||||
// Killing the remote mid-session must not drop the turn. That is the whole
|
||||
// point of the floor, and it is what TestPairFallsBackWhenRemoteFails pins.
|
||||
func (p *Pair) Transcribe(ctx context.Context, a audio.Audio) (string, float64, error) {
|
||||
if p.floor == nil {
|
||||
return "", 0, ErrNoFloor
|
||||
}
|
||||
if p.Available() {
|
||||
text, conf, err := p.remote.Transcribe(ctx, a)
|
||||
if err == nil {
|
||||
log.Print("stt: transcribed on the workstation")
|
||||
return text, conf, nil
|
||||
}
|
||||
// The cached answer was wrong. Correct it now rather than sending the
|
||||
// next utterance into the same hole, then fall back.
|
||||
p.set(false)
|
||||
log.Printf("stt: workstation failed mid-request, falling back: %v", err)
|
||||
}
|
||||
return p.floor.Transcribe(ctx, a)
|
||||
}
|
||||
|
||||
var _ Transcriber = (*Pair)(nil)
|
||||
@@ -0,0 +1,143 @@
|
||||
package stt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
)
|
||||
|
||||
// scripted — a Transcriber that answers with a fixed text, or fails.
|
||||
type scripted struct {
|
||||
text string
|
||||
err error
|
||||
calls atomic.Int32
|
||||
}
|
||||
|
||||
func (s *scripted) Transcribe(_ context.Context, _ audio.Audio) (string, float64, error) {
|
||||
s.calls.Add(1)
|
||||
if s.err != nil {
|
||||
return "", 0, s.err
|
||||
}
|
||||
return s.text, 0.9, nil
|
||||
}
|
||||
|
||||
func sample() audio.Audio {
|
||||
return audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 3200)}
|
||||
}
|
||||
|
||||
// up builds a Pair whose admission answer is already true, without probing.
|
||||
func up(remote, floor Transcriber) *Pair {
|
||||
p := NewPair(remote, floor, "", time.Minute)
|
||||
p.up.Store(true)
|
||||
return p
|
||||
}
|
||||
|
||||
func TestPairPrefersTheWorkstation(t *testing.T) {
|
||||
t.Parallel()
|
||||
remote := &scripted{text: "с рабочей станции"}
|
||||
floor := &scripted{text: "с homesrv"}
|
||||
text, _, err := up(remote, floor).Transcribe(context.Background(), sample())
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if text != "с рабочей станции" {
|
||||
t.Fatalf("want the remote transcript, got %q", text)
|
||||
}
|
||||
if floor.calls.Load() != 0 {
|
||||
t.Fatalf("floor was called %d times, want 0", floor.calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// The turn is what matters. A remote that dies mid-session must cost a worse
|
||||
// transcript and nothing else. This is the V-486 bar.
|
||||
func TestPairFallsBackWhenRemoteFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
remote := &scripted{err: errors.New("connection refused")}
|
||||
floor := &scripted{text: "с homesrv"}
|
||||
p := up(remote, floor)
|
||||
|
||||
text, conf, err := p.Transcribe(context.Background(), sample())
|
||||
if err != nil {
|
||||
t.Fatalf("a failed remote must not fail the turn: %v", err)
|
||||
}
|
||||
if text != "с homesrv" {
|
||||
t.Fatalf("want the floor transcript, got %q", text)
|
||||
}
|
||||
if conf != 0.9 {
|
||||
t.Fatalf("want the floor confidence, got %v", conf)
|
||||
}
|
||||
if p.Available() {
|
||||
t.Fatal("a failed request must correct the cached admission answer")
|
||||
}
|
||||
|
||||
// The next utterance goes straight to the floor rather than into the
|
||||
// same hole.
|
||||
if _, _, err := p.Transcribe(context.Background(), sample()); err != nil {
|
||||
t.Fatalf("second turn: %v", err)
|
||||
}
|
||||
if remote.calls.Load() != 1 {
|
||||
t.Fatalf("remote called %d times, want 1", remote.calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairWithNoRemoteIsTheFloor(t *testing.T) {
|
||||
t.Parallel()
|
||||
floor := &scripted{text: "с homesrv"}
|
||||
p := NewPair(nil, floor, "", time.Minute)
|
||||
p.Start(context.Background()) // no health url, so this is a no-op
|
||||
if p.Available() {
|
||||
t.Fatal("an unconfigured remote is never available")
|
||||
}
|
||||
text, _, err := p.Transcribe(context.Background(), sample())
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if text != "с homesrv" {
|
||||
t.Fatalf("want the floor transcript, got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairWithNoFloorRefuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := NewPair(nil, nil, "", time.Minute).Transcribe(context.Background(), sample())
|
||||
if !errors.Is(err, ErrNoFloor) {
|
||||
t.Fatalf("want ErrNoFloor, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairProbeReadsHealth(t *testing.T) {
|
||||
t.Parallel()
|
||||
var ok atomic.Bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if !ok.Load() {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewPair(&scripted{text: "remote"}, &scripted{text: "floor"}, srv.URL, time.Minute)
|
||||
p.probe(context.Background())
|
||||
if p.Available() {
|
||||
t.Fatal("a 503 means the card is busy, so the workstation is not available")
|
||||
}
|
||||
ok.Store(true)
|
||||
p.probe(context.Background())
|
||||
if !p.Available() {
|
||||
t.Fatal("a 200 means the workstation will take work")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairStopIsIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := NewPair(nil, &scripted{}, "", time.Minute)
|
||||
p.Stop()
|
||||
p.Stop()
|
||||
}
|
||||
@@ -54,7 +54,11 @@ def turn(text):
|
||||
q = urllib.parse.parse_qs(urllib.parse.urlparse(loc).query)
|
||||
return {
|
||||
"reply": q.get("r", [""])[0],
|
||||
"source": q.get("src", q.get("source", [""]))[0],
|
||||
# "s", not "src". cmd/mavweb/chat.go writes the badge under that
|
||||
# name, and reading the wrong one cost both fortnight runs their
|
||||
# source column: every finding in those docs is inferred from the
|
||||
# reply wording instead.
|
||||
"source": q.get("s", [""])[0],
|
||||
"trace": q.get("t", [""])[0],
|
||||
"secs": dt,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user