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:
+3
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,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 — "какие у меня задачи?", "что мне нужно сделать?".
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -8,13 +8,13 @@ import (
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// TestFallbackPersona scores every line in fallbacks_ru_v1.json on the persona
|
||||
// checks the nudges are already held to. These lines are heard out loud, and
|
||||
// they live in a JSON file now, so a reworded variant that says "рад" or "вы"
|
||||
// would otherwise reach him with nothing between it and the speaker.
|
||||
// TestFallbackPersona scores every line in fallbacks_ru_v1.json and
|
||||
// ack_ru_v1.json on the persona checks the nudges already pass. These lines are
|
||||
// heard out loud and they live in a JSON file now, so a reworded variant that
|
||||
// says "рад" or "вы" would otherwise reach him with nothing in between.
|
||||
//
|
||||
// Only the persona checks run. Mood and topic belong to a nudge, and these are
|
||||
// not nudges: they are what she says when there is no answer.
|
||||
// not nudges.
|
||||
func TestFallbackPersona(t *testing.T) {
|
||||
fb, err := phraser.LoadFallbacks(rand.NewSource(20260804))
|
||||
if err != nil {
|
||||
@@ -24,13 +24,20 @@ func TestFallbackPersona(t *testing.T) {
|
||||
CheckLang: true, CheckFeminine: true, CheckHisGender: true,
|
||||
CheckAddress: true, CheckCringe: true, CheckLength: true,
|
||||
}
|
||||
variants := fb.Variants()
|
||||
ack, err := phraser.LoadAcks(rand.NewSource(20260804))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAcks: %v", err)
|
||||
}
|
||||
variants := append(fb.Variants(), ack.Variants()...)
|
||||
if len(variants) == 0 {
|
||||
t.Fatal("no variants — the file loaded empty")
|
||||
}
|
||||
for _, v := range variants {
|
||||
// {sources} stands for his own notes and never carries persona of its own.
|
||||
body := strings.ReplaceAll(v, "{sources}", "два литра")
|
||||
// The placeholders stand for his own words and carry no persona.
|
||||
body := v
|
||||
for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}"} {
|
||||
body = strings.ReplaceAll(body, ph, "вода")
|
||||
}
|
||||
for _, r := range RunChecks(Case{}, body, "neutral") {
|
||||
if persona[r.Name] && !r.Pass {
|
||||
t.Errorf("%q fails %s: %s", v, r.Name, r.Detail)
|
||||
|
||||
@@ -55,3 +55,20 @@ func TestFallbacksDoNotRepeat(t *testing.T) {
|
||||
prev = got
|
||||
}
|
||||
}
|
||||
|
||||
// The acknowledgements load, fill his words into the frame, and answer from the
|
||||
// floor when the file is gone.
|
||||
func TestAcksLoad(t *testing.T) {
|
||||
a, err := LoadAcks(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAcks: %v", err)
|
||||
}
|
||||
got := a.Say(AckFactValue, map[string]string{"key": "вода", "value": "2л"})
|
||||
if !strings.Contains(got, "вода") || !strings.Contains(got, "2л") {
|
||||
t.Errorf("Say(%s) = %q, want his key and value in it", AckFactValue, got)
|
||||
}
|
||||
var nilAcks *Acks
|
||||
if got := nilAcks.Say(AckNote, nil); got != ackFloor[AckNote] {
|
||||
t.Errorf("nil Acks said %q, want the floor %q", got, ackFloor[AckNote])
|
||||
}
|
||||
}
|
||||
|
||||
+11
-11
@@ -25,7 +25,10 @@
|
||||
// the daemon seam (config wiring, no CoreAPI or voice-package change).
|
||||
package voice
|
||||
|
||||
import "github.com/kami/maven/internal/router"
|
||||
import (
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// Replier — the reactive reply phrasing seam. The daemon's reactive handler
|
||||
// calls Reply with the router's Decision; the impl produces a terse reply
|
||||
@@ -60,27 +63,24 @@ func (s *StubReplier) Reply(d router.Decision) string {
|
||||
if !d.Slots.HasFn {
|
||||
return "не могу это сделать — не разобрала действие."
|
||||
}
|
||||
return "ок, записала действие: " + d.Slots.Fn
|
||||
return phraser.Ack(phraser.AckAct, map[string]string{"fn": d.Slots.Fn})
|
||||
case router.IntentReminder:
|
||||
if d.Slots.HasTime {
|
||||
return "напомню."
|
||||
}
|
||||
return "напомню."
|
||||
return phraser.Ack(phraser.AckReminder, nil)
|
||||
case router.IntentFact:
|
||||
if d.Slots.HasKey {
|
||||
if d.Slots.Value != "" {
|
||||
return "отметила: " + d.Slots.Key + " = " + d.Slots.Value
|
||||
return phraser.Ack(phraser.AckFactValue, map[string]string{"key": d.Slots.Key, "value": d.Slots.Value})
|
||||
}
|
||||
return "отметила: " + d.Slots.Key
|
||||
return phraser.Ack(phraser.AckFactKey, map[string]string{"key": d.Slots.Key})
|
||||
}
|
||||
return "записала факт."
|
||||
return phraser.Ack(phraser.AckFact, nil)
|
||||
case router.IntentNote:
|
||||
return "сохранила заметку."
|
||||
return phraser.Ack(phraser.AckNote, nil)
|
||||
case router.IntentQuery:
|
||||
return "поискала в заметках — ничего не нашла."
|
||||
case router.IntentChat:
|
||||
return "поговорили." // stub — LLMReplier replaces this
|
||||
default:
|
||||
return "приняла."
|
||||
return phraser.Ack(phraser.AckGeneric, nil)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user