mavend, voice: say the acknowledgements from the file (V-502)

The daemon tests that compared against one literal ask the entry instead: IsAck
names the line she could have said without pinning the wording. The eval scores
every ack variant on the persona checks the nudges already pass.
This commit is contained in:
2026-08-04 01:26:52 +04:00
parent dae123adac
commit b2521988e1
12 changed files with 82 additions and 39 deletions
+3 -2
View File
@@ -16,6 +16,7 @@ import (
"log"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
@@ -35,10 +36,10 @@ func (h *reactiveHandler) resolveAck(ctx context.Context, text string, src turnS
}
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeActed, now); err != nil {
log.Printf("voice: ack nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
return "не получилось отметить.", true
return phraser.Ack(phraser.FailAck, nil), true
}
log.Printf("voice: acked nudge %d (rule %s) from %s", target.ID, target.Rule, src)
return "отлично, отметила.", true
return phraser.Ack(phraser.AckNudge, nil), true
}
// ackFromFact — post-action hook, called once the turn's decision has been
+3 -2
View File
@@ -6,6 +6,7 @@ import (
"strconv"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
@@ -14,7 +15,7 @@ import (
// it for recall, and let pattern detection propose a routine.
func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) string {
if !dec.Slots.HasKey {
return "не разобрала, что записать — попробуй иначе."
return phraser.Ack(phraser.FailFactUnparsed, nil)
}
// A question is never a fact about him (#470). "какая последняя версия
// языка Go?" used to land here, and the value stored was whatever the
@@ -62,7 +63,7 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
factID, err := h.api.WriteFact(ctx, req)
if err != nil {
log.Printf("voice: write fact: %v", err)
return "не получилось сохранить факт."
return phraser.Ack(phraser.FailFact, nil)
}
// Index the fact 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
+3 -2
View File
@@ -5,6 +5,7 @@ import (
"log"
"strconv"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
@@ -23,13 +24,13 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance)
if err != nil {
log.Printf("voice: embed note: %v", err)
return "не получилось сохранить заметку."
return phraser.Ack(phraser.FailNote, nil)
}
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 "не получилось сохранить заметку."
return phraser.Ack(phraser.FailNote, nil)
}
// 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).
+3 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"log"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
@@ -21,13 +22,13 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
}
}
if !dec.Slots.HasTime {
return "не получилось разобрать время напоминания."
return phraser.Ack(phraser.FailReminderTime, nil)
}
}
payload := `{"text":` + jsonString(dec.Utterance) + `}`
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
log.Printf("voice: create reminder: %v", err)
return "не получилось поставить напоминание."
return phraser.Ack(phraser.FailReminder, nil)
}
return ""
}
+5 -4
View File
@@ -5,6 +5,7 @@ import (
"log"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/tasks"
@@ -40,18 +41,18 @@ func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.De
})
if err != nil {
log.Printf("voice: capture task: %v", err)
return "не получилось записать задачу.", true
return phraser.Ack(phraser.FailTask, nil), true
}
if resp.Promoted {
// It was a candidate Maven derived from something she read, and he has
// now said it himself. Saying "уже в списке" here would be answering a
// confirmation with a shrug.
return "поняла, беру в работу: " + cap.Text, true
return phraser.Ack(phraser.AckTaskUrgent, map[string]string{"text": cap.Text}), true
}
if !resp.Created {
return "это уже в списке.", true
return phraser.Ack(phraser.AckTaskDuplicate, nil), true
}
return "записала: " + cap.Text, true
return phraser.Ack(phraser.AckTask, map[string]string{"text": cap.Text}), true
}
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
+2 -1
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
@@ -108,7 +109,7 @@ func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) {
if !ok {
t.Fatal("a failed capture still claims the turn — the note path must not double-write")
}
if !strings.Contains(reply, "не получилось") {
if !phraser.IsAck(phraser.FailTask, nil, reply) {
t.Errorf("reply = %q, want an honest failure", reply)
}
}
+4 -3
View File
@@ -11,6 +11,7 @@ import (
"unicode"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
)
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
@@ -32,10 +33,10 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
return "", false
}
val := "false"
reply := "тихий режим выключен."
reply := phraser.Ack(phraser.AckQuietOff, nil)
if on {
val = "true"
reply = "тихий режим включён. буду реже напоминать."
reply = phraser.Ack(phraser.AckQuietOn, nil)
}
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
Ts: h.now(),
@@ -46,7 +47,7 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
Confidence: 1.0,
}); err != nil {
log.Printf("voice: write quiet_hours: %v", err)
return "не получилось переключить тихий режим.", true
return phraser.Ack(phraser.FailQuiet, nil), true
}
return reply, true
}
+13 -2
View File
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/kami/maven/internal/llm"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/voice"
)
@@ -29,12 +30,12 @@ func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) {
func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
r := newLLMReplier(stubCompleter{err: errReplierTest}, nil)
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "llm error")
assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "llm error")
}
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
r := newLLMReplier(stubCompleter{out: ""}, nil)
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "empty llm")
assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "empty llm")
}
func TestLLMReplierClarifyUsesStub(t *testing.T) {
@@ -42,6 +43,16 @@ func TestLLMReplierClarifyUsesStub(t *testing.T) {
assertStub(t, r, router.Decision{Clarify: true}, "clarify")
}
// assertAck — the stub picks between variants now, so two calls to it are not
// expected to match. What must hold is that the reply is a line that entry can
// produce, which is the same claim without pinning one wording.
func assertAck(t *testing.T, r *llmReplier, d router.Decision, key, what string) {
t.Helper()
if got := r.Reply(d); !phraser.IsAck(key, nil, got) {
t.Errorf("on %s: got %q, want a %q line", what, got, key)
}
}
func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) {
t.Helper()
got, want := r.Reply(d), voice.NewStubReplier().Reply(d)
+3 -2
View File
@@ -11,6 +11,7 @@ import (
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/store"
)
@@ -51,10 +52,10 @@ func (h *reactiveHandler) resolveSnooze(ctx context.Context, text string, src tu
}
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeSnoozed, now); err != nil {
log.Printf("voice: snooze nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
return "не получилось отложить.", true
return phraser.Ack(phraser.FailSnooze, nil), true
}
log.Printf("voice: snoozed nudge %d (rule %s) from %s", target.ID, target.Rule, src)
return "хорошо, вернусь к этому позже.", true
return phraser.Ack(phraser.AckSnooze, nil), true
}
// pendingNudge — the newest still-pending nudge sent inside snoozeWindow.