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:
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user