diff --git a/cmd/mavend/ack.go b/cmd/mavend/ack.go index 9fcd430..7339a40 100644 --- a/cmd/mavend/ack.go +++ b/cmd/mavend/ack.go @@ -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 diff --git a/cmd/mavend/actions_fact.go b/cmd/mavend/actions_fact.go index 5645282..d902642 100644 --- a/cmd/mavend/actions_fact.go +++ b/cmd/mavend/actions_fact.go @@ -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 diff --git a/cmd/mavend/actions_note.go b/cmd/mavend/actions_note.go index c7c20a5..f8b79b5 100644 --- a/cmd/mavend/actions_note.go +++ b/cmd/mavend/actions_note.go @@ -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). diff --git a/cmd/mavend/actions_reminder.go b/cmd/mavend/actions_reminder.go index ce2a632..ab9a544 100644 --- a/cmd/mavend/actions_reminder.go +++ b/cmd/mavend/actions_reminder.go @@ -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 "" } diff --git a/cmd/mavend/actions_task.go b/cmd/mavend/actions_task.go index 99187f0..a2dded3 100644 --- a/cmd/mavend/actions_task.go +++ b/cmd/mavend/actions_task.go @@ -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 — "какие у меня задачи?", "что мне нужно сделать?". diff --git a/cmd/mavend/actions_task_test.go b/cmd/mavend/actions_task_test.go index 628b30d..33e304c 100644 --- a/cmd/mavend/actions_task_test.go +++ b/cmd/mavend/actions_task_test.go @@ -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) } } diff --git a/cmd/mavend/quiet_toggle.go b/cmd/mavend/quiet_toggle.go index 1bbd922..02f6e10 100644 --- a/cmd/mavend/quiet_toggle.go +++ b/cmd/mavend/quiet_toggle.go @@ -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 } diff --git a/cmd/mavend/replier_llm_test.go b/cmd/mavend/replier_llm_test.go index fae6d39..075716a 100644 --- a/cmd/mavend/replier_llm_test.go +++ b/cmd/mavend/replier_llm_test.go @@ -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) diff --git a/cmd/mavend/snooze.go b/cmd/mavend/snooze.go index 88fb235..f92c981 100644 --- a/cmd/mavend/snooze.go +++ b/cmd/mavend/snooze.go @@ -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. diff --git a/internal/phraser/eval/fallbacks_test.go b/internal/phraser/eval/fallbacks_test.go index 3794a54..55c4fe5 100644 --- a/internal/phraser/eval/fallbacks_test.go +++ b/internal/phraser/eval/fallbacks_test.go @@ -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) diff --git a/internal/phraser/fallbacks_test.go b/internal/phraser/fallbacks_test.go index ecec0ec..789badc 100644 --- a/internal/phraser/fallbacks_test.go +++ b/internal/phraser/fallbacks_test.go @@ -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]) + } +} diff --git a/internal/voice/replier.go b/internal/voice/replier.go index 1f6e71f..b1c5cca 100644 --- a/internal/voice/replier.go +++ b/internal/voice/replier.go @@ -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) } }