maven: wire dialogue slot carry-over into the voice path (task 6)

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 <noreply@anthropic.com>
This commit is contained in:
kami
2026-07-06 12:15:17 +04:00
parent 9c2ccad7ef
commit 388d4257ee
3 changed files with 223 additions and 15 deletions
+55
View File
@@ -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
}
+98
View File
@@ -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)
}
})
}
+70 -15
View File
@@ -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 == "" {