Files
Maven/cmd/mavend/replier_llm_test.go
claude 85a3397bf4 Cancel a reminder by voice, and honour a refusal (V-719)
reminder_cancel.go is a stateful pre-route resolver ahead of a parked
clarification and the statistical cascade. It accepts only an addressed
command-position imperative plus the reminder or alarm noun, so questions,
reported speech, past-tense reports and prohibitions establish no mutation
authority. Subject terms keep negation and quantity, and a parsed time
passes the same resolved-hour gate as capture.

One match cancels through the typed IPC method. Several are stored as
session candidates in the spoken order, capped at five, and only a whole
affirmative ordinal consumes that list: re-querying on the follow-up would
let a state change move the ordinal underneath him. No match, an unread
time, a spent ordinal and an ambiguous delivery result are all explicit
no-ops.

command_prohibition.go is the first mutation boundary in a turn. A direct
prohibition clears the three confirmation slots under their shared mutex,
so a later bare "да" cannot revive authority he has just revoked. A parked
clarify question is not authority and survives, suspended and repeated.
refusesCommand is the same belt at the executor entry points, checked
against the original utterance so a model rewriting Slots.Text cannot get
around it.

The rung is named in preRouteLadder, so /trace records whether it won or
declined on every surface.

--no-verify: master is the working branch this session by the owner's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:19:25 +04:00

105 lines
3.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"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"
)
// The phrasing itself is tested in internal/phraser. What is left here is the
// only thing the daemon adds: the stub floor, on the three ways a reply can
// fail to arrive.
type stubCompleter struct {
out string
err error
}
func (s stubCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { return s.out, s.err }
func TestLLMReplierPassesTheModelReplyThroughForOtherIntents(t *testing.T) {
r := newLLMReplier(stubCompleter{out: `{"response":"записала, кофе закончился","mood":"neutral"}`}, nil)
got := r.Reply(context.Background(), router.Decision{Intent: router.IntentReminder, Slots: router.Slots{Text: "кофе закончился"}})
if got != "записала, кофе закончился" {
t.Errorf("got %q, want %q", got, "записала, кофе закончился")
}
}
type countingCompleter struct {
out string
calls int
}
func (c *countingCompleter) Complete(_ context.Context, _ llm.Req) (string, error) {
c.calls++
return c.out, nil
}
func TestLLMReplierNoteUsesFixedFeminineAcknowledgement(t *testing.T) {
c := &countingCompleter{out: `{"response":"Хорошо, сохранил.","mood":"neutral"}`}
r := newLLMReplier(c, nil)
got := r.Reply(context.Background(), router.Decision{
Intent: router.IntentNote, Slots: router.Slots{Text: "запасной ключ лежит в синей коробке"},
})
if c.calls != 0 {
t.Fatalf("note acknowledgement called the resident model %d time(s), want none", c.calls)
}
if got != "сохранила заметку." {
t.Fatalf("note acknowledgement = %q, want the fixed feminine line", got)
}
}
func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
r := newLLMReplier(stubCompleter{err: errReplierTest}, nil)
assertAck(t, r, router.Decision{Intent: router.IntentReminder}, phraser.AckReminder, "llm error")
}
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
r := newLLMReplier(stubCompleter{out: ""}, nil)
assertAck(t, r, router.Decision{Intent: router.IntentReminder}, phraser.AckReminder, "empty llm")
}
// A clarify never reaches the model, and since Vikunja #457 it is answered from
// the clarify deck rather than the stub's single sentence.
func TestLLMReplierClarifyReadsTheDeck(t *testing.T) {
r := newLLMReplier(stubCompleter{out: "я всё поняла"}, nil)
got := r.Reply(context.Background(), router.Decision{Clarify: true, Utterance: "мгм"})
if got == "я всё поняла" {
t.Fatal("a clarify must not be phrased by the model")
}
if want := clarifyMissedFor("мгм"); got != want {
t.Errorf("on clarify: got %q, want %q", got, want)
}
// Two different misses do not sound identical.
if same := r.Reply(context.Background(), router.Decision{Clarify: true, Utterance: "а"}); same == got {
t.Log("two utterances hashed to the same line, which is allowed but should be rare")
}
}
// 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(context.Background(), 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(context.Background(), d), voice.NewStubReplier().Reply(context.Background(), d)
if got != want {
t.Errorf("on %s: got %q, want stub %q", what, got, want)
}
}
var errReplierTest = errTest("llm down")
type errTest string
func (e errTest) Error() string { return string(e) }