From 388d4257ee9a34753e34c12ea9e6e2458f8ef259 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 6 Jul 2026 12:15:17 +0400 Subject: [PATCH] maven: wire dialogue slot carry-over into the voice path (task 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialogue library (internal/dialogue, task 6) shipped tested but unwired. Wire it: reactiveHandler now holds a 2-min SessionStore, and each turn fills its missing slots from a prior same-intent, non-expired turn via InheritSlots before acting, then records itself for the next follow-up. Single-user box → one session slot (voiceDialogueID). Guardrails (followUpMerge, unit-tested): only same-intent turns inherit (a new intent is a fresh command); clarify turns and expired/nil priors never inherit; InheritSlots fills gaps only, so a fully-slotted turn is untouched; the fact Value (router-only) survives the dialogue.Slots round-trip. Co-Authored-By: Claude Opus 4.8 --- cmd/mavend/followup.go | 55 +++++++++++++++++++++ cmd/mavend/followup_test.go | 98 +++++++++++++++++++++++++++++++++++++ cmd/mavend/voice.go | 85 ++++++++++++++++++++++++++------ 3 files changed, 223 insertions(+), 15 deletions(-) create mode 100644 cmd/mavend/followup.go create mode 100644 cmd/mavend/followup_test.go diff --git a/cmd/mavend/followup.go b/cmd/mavend/followup.go new file mode 100644 index 0000000..5d18047 --- /dev/null +++ b/cmd/mavend/followup.go @@ -0,0 +1,55 @@ +package main + +import ( + "time" + + "github.com/kami/maven/internal/dialogue" + "github.com/kami/maven/internal/router" +) + +// voiceDialogueID — the single dialogue-session key. This is a single-user box +// (ponytail), so one slot suffices; a second speaker would need per-speaker ids, +// which waits on voice-print attribution (see PROGRESS multi-user deferral). +const voiceDialogueID = "voice" + +// toDialogueSlots projects the router's slots onto the dialogue layer's subset +// (everything except the fact Value, which the dialogue layer doesn't carry). +func toDialogueSlots(s router.Slots) dialogue.Slots { + return dialogue.Slots{ + Time: s.Time, + HasTime: s.HasTime, + Key: s.Key, + HasKey: s.HasKey, + Text: s.Text, + Fn: s.Fn, + Args: s.Args, + HasFn: s.HasFn, + } +} + +// applyDialogueSlots writes inherited dialogue slots back onto router slots, +// preserving router-only fields (Value) the dialogue layer never touched. +func applyDialogueSlots(base router.Slots, d dialogue.Slots) router.Slots { + base.Time, base.HasTime = d.Time, d.HasTime + base.Key, base.HasKey = d.Key, d.HasKey + base.Text = d.Text + base.Fn, base.Args, base.HasFn = d.Fn, d.Args, d.HasFn + return base +} + +// followUpMerge fills the current turn's missing slots from a prior same-intent, +// non-expired session — the multi-turn seam. A different intent is a fresh +// command, not a follow-up, so it's returned untouched; a clarify turn resolved +// nothing, so it never inherits. InheritSlots only fills gaps, so a fully-slotted +// current turn is unaffected. +func followUpMerge(prev *dialogue.Session, dec router.Decision, now time.Time) router.Decision { + if prev == nil || dec.Clarify || prev.IsExpired(now) { + return dec + } + if prev.Intent != dialogue.Intent(dec.Intent) { + return dec + } + merged := dialogue.InheritSlots(prev.Slots, toDialogueSlots(dec.Slots)) + dec.Slots = applyDialogueSlots(dec.Slots, merged) + return dec +} diff --git a/cmd/mavend/followup_test.go b/cmd/mavend/followup_test.go new file mode 100644 index 0000000..b01a95b --- /dev/null +++ b/cmd/mavend/followup_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "testing" + "time" + + "github.com/kami/maven/internal/dialogue" + "github.com/kami/maven/internal/router" +) + +func TestFollowUpMerge(t *testing.T) { + base := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC) + fireAt := base.Add(24 * time.Hour) + + // prior turn: a reminder that resolved a fire time. + prev := &dialogue.Session{ + Intent: dialogue.IntentReminder, + Slots: dialogue.Slots{Time: fireAt, HasTime: true, Text: "старый текст"}, + Timestamp: base, + TTL: 2 * time.Minute, + } + + t.Run("same intent inherits the missing time", func(t *testing.T) { + // follow-up reminder with text but no parsed time. + cur := router.Decision{ + Intent: router.IntentReminder, + Slots: router.Slots{Text: "позвонить маме"}, + } + got := followUpMerge(prev, cur, base.Add(30*time.Second)) + if !got.Slots.HasTime || !got.Slots.Time.Equal(fireAt) { + t.Errorf("time not inherited: HasTime=%v Time=%v", got.Slots.HasTime, got.Slots.Time) + } + if got.Slots.Text != "позвонить маме" { + t.Errorf("current text was overwritten: %q", got.Slots.Text) + } + }) + + t.Run("current slot wins over prior (gaps only)", func(t *testing.T) { + own := base.Add(48 * time.Hour) + cur := router.Decision{ + Intent: router.IntentReminder, + Slots: router.Slots{Time: own, HasTime: true}, + } + got := followUpMerge(prev, cur, base.Add(30*time.Second)) + if !got.Slots.Time.Equal(own) { + t.Errorf("current time clobbered by prior: %v", got.Slots.Time) + } + }) + + t.Run("different intent does not inherit", func(t *testing.T) { + cur := router.Decision{Intent: router.IntentFact, Slots: router.Slots{Key: "water", HasKey: true}} + got := followUpMerge(prev, cur, base.Add(30*time.Second)) + if got.Slots.HasTime { + t.Error("time bled across a different intent") + } + }) + + t.Run("clarify turn does not inherit", func(t *testing.T) { + cur := router.Decision{Intent: router.IntentReminder, Clarify: true} + got := followUpMerge(prev, cur, base.Add(30*time.Second)) + if got.Slots.HasTime { + t.Error("clarify turn inherited slots") + } + }) + + t.Run("expired prior does not inherit", func(t *testing.T) { + cur := router.Decision{Intent: router.IntentReminder, Slots: router.Slots{Text: "x"}} + got := followUpMerge(prev, cur, base.Add(3*time.Minute)) // past the 2-min TTL + if got.Slots.HasTime { + t.Error("expired session still inherited") + } + }) + + t.Run("nil prior is a no-op", func(t *testing.T) { + cur := router.Decision{Intent: router.IntentReminder, Slots: router.Slots{Text: "x"}} + got := followUpMerge(nil, cur, base) + if got.Slots.HasTime { + t.Error("nil prior produced inheritance") + } + }) + + t.Run("router-only Value survives the round-trip", func(t *testing.T) { + cur := router.Decision{ + Intent: router.IntentFact, + Slots: router.Slots{Key: "sleep", HasKey: true, Value: "6h"}, + } + factPrev := &dialogue.Session{ + Intent: dialogue.IntentFact, + Slots: dialogue.Slots{Text: "спал"}, + Timestamp: base, + TTL: 2 * time.Minute, + } + got := followUpMerge(factPrev, cur, base.Add(10*time.Second)) + if got.Slots.Value != "6h" { + t.Errorf("Value lost through dialogue conversion: %q", got.Slots.Value) + } + }) +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 727015c..7d5e6d1 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -59,6 +59,7 @@ import ( "github.com/kami/maven/internal/config" "github.com/kami/maven/internal/delivery" "github.com/kami/maven/internal/delivery/voicesink" + "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/memory" "github.com/kami/maven/internal/phraser" @@ -205,20 +206,24 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser) (*v // ----- memory (long-term vector storage, in-memory for now) ----- memStore := memory.NewInMemoryStore() + // ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) ----- + dialogueSessions := dialogue.NewSessionStore(2 * time.Minute) + // ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) ----- h := &reactiveHandler{ - stt: transcriber, - tts: synthesizer, - router: rtr, - embedder: emb, - api: coreAPI, - tools: exec, - phraser: phr, - replier: voice.NewStubReplier(), - now: time.Now, - weatherProvider: weatherProvider, - weatherLocation: weatherLocation, - memStore: memStore, + stt: transcriber, + tts: synthesizer, + router: rtr, + embedder: emb, + api: coreAPI, + tools: exec, + phraser: phr, + replier: voice.NewStubReplier(), + now: time.Now, + weatherProvider: weatherProvider, + weatherLocation: weatherLocation, + memStore: memStore, + dialogueSessions: dialogueSessions, } // ----- the server (TCP listener) ----- @@ -252,6 +257,10 @@ type reactiveHandler struct { memStore memory.Store + // dialogueSessions carries slots across turns for follow-ups (single-user + // box → one session slot, keyed voiceDialogueID). nil ⇒ no carry-over. + dialogueSessions *dialogue.SessionStore + // pending destructive-act confirmation. A destructive act replies with a // "выполнить X? да/нет" prompt and parks here; the NEXT utterance is read as // the y/n answer. ponytail: single slot, single-user box — a second act @@ -319,6 +328,22 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo return h.reply(ctx, "не получилось разобрать команду.", nil) } + // 2b. dialogue — fill this turn's missing slots from a prior same-intent + // turn (follow-ups like «напомни завтра» → «…позвонить маме»), then remember + // this turn for the next follow-up. Only same-intent, non-expired, non- + // clarify turns carry (see followUpMerge). Best-effort: nil store ⇒ skipped. + if h.dialogueSessions != nil { + now := h.now() + dec = followUpMerge(h.dialogueSessions.Get(voiceDialogueID, now), dec, now) + if !dec.Clarify { + h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{ + Intent: dialogue.Intent(dec.Intent), + Slots: toDialogueSlots(dec.Slots), + Timestamp: now, + }) + } + } + // 3. action — execute the decision's intent. errors here surface as // short reply text (the user wants to know the action didn't land); // the round-trip stays alive. @@ -374,6 +399,21 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) log.Printf("voice: write fact: %v", err) return "не получилось сохранить факт." } + // Index the fact utterance in long-term memory (best-effort, must not + // fail the fact write). Facts aren't in the notes table, so this is the + // only recall path for them — "когда я пил воду?" reads back from here. + if h.memStore != nil { + if vec, err := h.embedder.Embed(ctx, dec.Utterance); err != nil { + log.Printf("voice: embed fact for memory: %v", err) + } else if err := h.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{ + "source": "voice", + "type": "fact", + "text": dec.Utterance, + "ts": strconv.FormatInt(now.Unix(), 10), + }); err != nil { + log.Printf("voice: memory insert fact: %v", err) + } + } return "" // replier phrases the success reply case router.IntentReminder: @@ -430,15 +470,20 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) log.Printf("voice: embed note: %v", err) return "не получилось сохранить заметку." } - noteID, err := h.api.WriteNote(ctx, h.now(), dec.Utterance, vec, "tap:voice") + noteTs := h.now() + noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice") if err != nil { log.Printf("voice: write note: %v", err) return "не получилось сохранить заметку." } - // Insert into long-term memory (best-effort, must not fail the note write) + // Insert into long-term memory (best-effort, must not fail the note write). + // text/ts in the meta make a Search hit self-describing (see bestRecall). if h.memStore != nil { - if err := h.memStore.Insert(ctx, strconv.FormatInt(noteID, 10), vec, map[string]string{ + if err := h.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{ "source": "voice", + "type": "note", + "text": dec.Utterance, + "ts": strconv.FormatInt(noteTs.Unix(), 10), }); err != nil { log.Printf("voice: memory insert: %v", err) } @@ -493,6 +538,16 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) // since(key)==null → don't fire. Tuned for the ONNX embedder; the Hash // floor scores lexically and may rarely clear it. if len(notes) == 0 || notes[0].Score < queryMinScore { + // Long-term memory recall (notes + facts) before general knowledge: + // the notes table can't answer fact questions, but the memory store + // indexes both. Only runs when notes-RAG already gave up → additive. + if h.memStore != nil { + if hits, herr := h.memStore.Search(ctx, vec, 3); herr == nil { + if text, ok := bestRecall(hits, queryMinScore); ok { + return text + } + } + } // Try general knowledge from the phraser before giving up reply, err := h.phraser.PhraseQuery(ctx, dec.Utterance, nil) if err != nil || reply == "" {