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/config"
|
||||||
"github.com/kami/maven/internal/delivery"
|
"github.com/kami/maven/internal/delivery"
|
||||||
"github.com/kami/maven/internal/delivery/voicesink"
|
"github.com/kami/maven/internal/delivery/voicesink"
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/memory"
|
"github.com/kami/maven/internal/memory"
|
||||||
"github.com/kami/maven/internal/phraser"
|
"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) -----
|
// ----- memory (long-term vector storage, in-memory for now) -----
|
||||||
memStore := memory.NewInMemoryStore()
|
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) -----
|
// ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) -----
|
||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
stt: transcriber,
|
stt: transcriber,
|
||||||
tts: synthesizer,
|
tts: synthesizer,
|
||||||
router: rtr,
|
router: rtr,
|
||||||
embedder: emb,
|
embedder: emb,
|
||||||
api: coreAPI,
|
api: coreAPI,
|
||||||
tools: exec,
|
tools: exec,
|
||||||
phraser: phr,
|
phraser: phr,
|
||||||
replier: voice.NewStubReplier(),
|
replier: voice.NewStubReplier(),
|
||||||
now: time.Now,
|
now: time.Now,
|
||||||
weatherProvider: weatherProvider,
|
weatherProvider: weatherProvider,
|
||||||
weatherLocation: weatherLocation,
|
weatherLocation: weatherLocation,
|
||||||
memStore: memStore,
|
memStore: memStore,
|
||||||
|
dialogueSessions: dialogueSessions,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- the server (TCP listener) -----
|
// ----- the server (TCP listener) -----
|
||||||
@@ -252,6 +257,10 @@ type reactiveHandler struct {
|
|||||||
|
|
||||||
memStore memory.Store
|
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
|
// pending destructive-act confirmation. A destructive act replies with a
|
||||||
// "выполнить X? да/нет" prompt and parks here; the NEXT utterance is read as
|
// "выполнить X? да/нет" prompt and parks here; the NEXT utterance is read as
|
||||||
// the y/n answer. ponytail: single slot, single-user box — a second act
|
// 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)
|
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
|
// 3. action — execute the decision's intent. errors here surface as
|
||||||
// short reply text (the user wants to know the action didn't land);
|
// short reply text (the user wants to know the action didn't land);
|
||||||
// the round-trip stays alive.
|
// 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)
|
log.Printf("voice: write fact: %v", err)
|
||||||
return "не получилось сохранить факт."
|
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
|
return "" // replier phrases the success reply
|
||||||
|
|
||||||
case router.IntentReminder:
|
case router.IntentReminder:
|
||||||
@@ -430,15 +470,20 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
|
|||||||
log.Printf("voice: embed note: %v", err)
|
log.Printf("voice: embed note: %v", err)
|
||||||
return "не получилось сохранить заметку."
|
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 {
|
if err != nil {
|
||||||
log.Printf("voice: write note: %v", err)
|
log.Printf("voice: write note: %v", err)
|
||||||
return "не получилось сохранить заметку."
|
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 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",
|
"source": "voice",
|
||||||
|
"type": "note",
|
||||||
|
"text": dec.Utterance,
|
||||||
|
"ts": strconv.FormatInt(noteTs.Unix(), 10),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("voice: memory insert: %v", err)
|
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
|
// since(key)==null → don't fire. Tuned for the ONNX embedder; the Hash
|
||||||
// floor scores lexically and may rarely clear it.
|
// floor scores lexically and may rarely clear it.
|
||||||
if len(notes) == 0 || notes[0].Score < queryMinScore {
|
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
|
// Try general knowledge from the phraser before giving up
|
||||||
reply, err := h.phraser.PhraseQuery(ctx, dec.Utterance, nil)
|
reply, err := h.phraser.PhraseQuery(ctx, dec.Utterance, nil)
|
||||||
if err != nil || reply == "" {
|
if err != nil || reply == "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user