85a3397bf4
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>
105 lines
3.9 KiB
Go
105 lines
3.9 KiB
Go
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) }
|