ea0eb167fd
The clarify path was gated on dec.Clarify, so a turn the cascade routed confidently but incompletely skipped it. "напомни позвонить" reached applyAction, failed on the missing time and parked nothing, and the "в семь вечера" that followed was routed as a world question and web-searched. The gate now also fires when missingFor names a required slot. A bare capture verb gets a stage-0 rule of its own: it was reaching the resident model as chat, which answered by agreeing to a wording change nobody asked for.
726 lines
32 KiB
Go
726 lines
32 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/kami/maven/internal/dialogue"
|
||
"github.com/kami/maven/internal/ipc"
|
||
"github.com/kami/maven/internal/memory"
|
||
"github.com/kami/maven/internal/phraser/eval"
|
||
"github.com/kami/maven/internal/router"
|
||
"github.com/kami/maven/internal/store"
|
||
"github.com/kami/maven/internal/tool"
|
||
"github.com/kami/maven/internal/voice"
|
||
)
|
||
|
||
// newClarifyHandler builds a handler with the clarify path wired and no model:
|
||
// stub date parser, the real fact parser, and a matcher over whatever tools the
|
||
// test enabled. `now` is fixed so TTL behaviour is testable.
|
||
func newClarifyHandler(t *testing.T) (*reactiveHandler, *store.Store, *time.Time) {
|
||
t.Helper()
|
||
st := newTestStore(t)
|
||
api := ipc.NewStoreAPI(st)
|
||
now := time.Date(2026, 7, 31, 9, 0, 0, 0, time.UTC)
|
||
matcher := tool.NewMatcher(api)
|
||
h := &reactiveHandler{
|
||
api: api,
|
||
dataStore: st,
|
||
tools: tool.NewExecutor(api, 2*time.Second),
|
||
matcher: matcher,
|
||
replier: voice.NewStubReplier(),
|
||
now: func() time.Time { return now },
|
||
dialogueSessions: dialogue.NewSessionStore(2 * time.Minute),
|
||
clarifyStore: dialogue.NewClarifyStore(clarifyTTL),
|
||
extractor: router.Extractor{
|
||
Time: router.StubDateTimeParser{},
|
||
Acts: matcher,
|
||
Facts: router.DefaultFactParser{},
|
||
},
|
||
}
|
||
return h, st, &now
|
||
}
|
||
|
||
func clarifyDec(intent router.Intent, slots router.Slots, utterance string) router.Decision {
|
||
return router.Decision{Utterance: utterance, Stage: 3, Intent: intent, Slots: slots, Clarify: true}
|
||
}
|
||
|
||
// TestClarifyQuestionForMissingSlot pins which question goes with which gap, and
|
||
// which intents get no question at all.
|
||
func TestClarifyQuestionForMissingSlot(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
dec router.Decision
|
||
want string
|
||
asked bool
|
||
}{
|
||
{"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "Когда?", true},
|
||
{"fact without a key", clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши"), "Что записать?", true},
|
||
{"act without a fn", clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это"), "Что сделать?", true},
|
||
// A time with nothing to say at that time is still half a reminder, so
|
||
// the subject is what she asks about — not silence.
|
||
{"reminder that has a time but no subject", clarifyDec(router.IntentReminder, router.Slots{HasTime: true}, "напомни в 11"), "О чём напомнить?", true},
|
||
{"reminder that has both", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни в 11 позвонить маме"), "", false},
|
||
{"chat is never worth a question", clarifyDec(router.IntentChat, router.Slots{Text: "мгм"}, "мгм"), "", false},
|
||
{"query is never worth a question", clarifyDec(router.IntentQuery, router.Slots{Text: "а"}, "а"), "", false},
|
||
}
|
||
for _, tc := range cases {
|
||
_, got, asked := clarifyQuestion(tc.dec)
|
||
if asked != tc.asked || got != tc.want {
|
||
t.Errorf("%s: got (%q, %v), want (%q, %v)", tc.name, got, asked, tc.want, tc.asked)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestClarifyReminderCompletesOnAnswer is the whole point of the feature: she
|
||
// asks for the missing time and the answer creates the reminder.
|
||
func TestClarifyReminderCompletesOnAnswer(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st, _ := newClarifyHandler(t)
|
||
|
||
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
||
if !asked || question != "Когда?" {
|
||
t.Fatalf("expected the time question, got %q asked=%v", question, asked)
|
||
}
|
||
|
||
reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00")
|
||
if !handled {
|
||
t.Fatal("the answer to an open question must be consumed as an answer")
|
||
}
|
||
if reply == clarifyGaveUp {
|
||
t.Fatalf("a good answer must not drop the request: %q", reply)
|
||
}
|
||
|
||
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||
if err != nil || len(reminders) != 1 {
|
||
t.Fatalf("clarified reminder was not created: reminders=%v err=%v", reminders, err)
|
||
}
|
||
if !strings.Contains(reminders[0].Payload, "маме") {
|
||
t.Fatalf("the reminder lost the original request: %q", reminders[0].Payload)
|
||
}
|
||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||
t.Fatal("the question must be cleared once answered")
|
||
}
|
||
}
|
||
|
||
// TestClarifyFactCompletesOnAnswer — the fact path, where the answer carries
|
||
// both the key and the value.
|
||
func TestClarifyFactCompletesOnAnswer(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st, _ := newClarifyHandler(t)
|
||
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked {
|
||
t.Fatal("a fact with no key should be asked about")
|
||
}
|
||
if reply, handled := h.resolveClarifyAnswer(ctx, "пил воду"); !handled || reply == clarifyGaveUp {
|
||
t.Fatalf("answer should complete the fact, handled=%v reply=%q", handled, reply)
|
||
}
|
||
if fact, err := st.LatestFact(ctx, "water"); err != nil || fact.Key != "water" {
|
||
t.Fatalf("clarified fact was not written: fact=%+v err=%v", fact, err)
|
||
}
|
||
}
|
||
|
||
// TestClarifyAnswerAfterTTLIsANewRequest — a late answer is not an answer.
|
||
func TestClarifyAnswerAfterTTLIsANewRequest(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st, now := newClarifyHandler(t)
|
||
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||
t.Fatal("expected a question")
|
||
}
|
||
*now = now.Add(clarifyTTL + time.Second)
|
||
|
||
if reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00"); handled {
|
||
t.Fatalf("an answer past the TTL must fall through to normal routing, got %q", reply)
|
||
}
|
||
if reminders, err := st.DueReminders(ctx, now.Add(48*time.Hour)); err != nil || len(reminders) != 0 {
|
||
t.Fatalf("expired question must not create anything: reminders=%v err=%v", reminders, err)
|
||
}
|
||
}
|
||
|
||
// TestClarifyAsksThreeTimesThenSaysSo — three questions are allowed, the fourth
|
||
// is not, and running out is SPOKEN. Silence would read as "handled".
|
||
func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st, _ := newClarifyHandler(t)
|
||
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||
t.Fatal("expected a first question")
|
||
}
|
||
// Two more unclear answers ⇒ two more questions (3 asks in total).
|
||
for i := 2; i <= 3; i++ {
|
||
reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю")
|
||
if !handled {
|
||
t.Fatalf("answer %d must be consumed as an answer", i)
|
||
}
|
||
// The wording changes with the attempt (Vikunja #457): repeating a
|
||
// question he already failed to answer is the worst way to ask it.
|
||
want, _ := clarifyQuestionFor(dialogue.SlotTime, i)
|
||
if reply != want {
|
||
t.Fatalf("attempt %d should ask again as %q, got %q", i, want, reply)
|
||
}
|
||
if first, _ := clarifyQuestionFor(dialogue.SlotTime, 1); reply == first {
|
||
t.Fatalf("attempt %d repeated the first wording: %q", i, reply)
|
||
}
|
||
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
||
t.Fatalf("attempt %d must leave the question armed", i)
|
||
}
|
||
}
|
||
|
||
reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю")
|
||
if !handled || reply != clarifyGaveUp {
|
||
t.Fatalf("the fourth try must give up out loud, handled=%v reply=%q", handled, reply)
|
||
}
|
||
if reply == "" || strings.Contains(reply, "?") {
|
||
t.Fatalf("giving up must be spoken and must not be another question: %q", reply)
|
||
}
|
||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||
t.Fatal("a given-up request must leave no armed question")
|
||
}
|
||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
|
||
t.Fatalf("a given-up request must not create anything: reminders=%v err=%v", reminders, err)
|
||
}
|
||
}
|
||
|
||
// TestClarifyMaxAttemptsIsConfigurable — one question when the config says one.
|
||
func TestClarifyMaxAttemptsIsConfigurable(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, _, _ := newClarifyHandler(t)
|
||
h.clarifyMaxAttempts = 1
|
||
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||
t.Fatal("expected a question")
|
||
}
|
||
if reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю"); !handled || reply != clarifyGaveUp {
|
||
t.Fatalf("with max 1 she must give up at once, handled=%v reply=%q", handled, reply)
|
||
}
|
||
}
|
||
|
||
// TestClarifyRestatedAnswerWins — «в 11:00», then «нет, в 15:00». The second
|
||
// value is the one that lands.
|
||
func TestClarifyRestatedAnswerWins(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st, _ := newClarifyHandler(t)
|
||
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||
t.Fatal("expected a question")
|
||
}
|
||
// First answer parses, but re-park it by hand as if she had asked again:
|
||
// what matters here is that Answer prefers the newer value over the parked
|
||
// one, which is the case the daemon hits on a re-ask.
|
||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||
if q == nil {
|
||
t.Fatal("expected an armed question")
|
||
}
|
||
first := h.extractor.Extract(ctx, router.IntentReminder, "в 11:00", h.now())
|
||
q.Slots = q.Answer("в 11:00", toDialogueSlots(first))
|
||
|
||
if reply, handled := h.resolveClarifyAnswer(ctx, "нет, в 15:00"); !handled || reply == clarifyGaveUp {
|
||
t.Fatalf("the restated answer should complete the request, handled=%v reply=%q", handled, reply)
|
||
}
|
||
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||
if err != nil || len(reminders) != 1 {
|
||
t.Fatalf("expected one reminder: %v err=%v", reminders, err)
|
||
}
|
||
want := h.extractor.Extract(ctx, router.IntentReminder, "в 15:00", h.now())
|
||
if !reminders[0].FireTs.Equal(want.Time) {
|
||
t.Fatalf("reminder at %v, want the restated %v", reminders[0].FireTs, want.Time)
|
||
}
|
||
}
|
||
|
||
// TestActOffAllowlistIsStillRefused — naming a capability is not being granted
|
||
// one. Since Vikunja #556 an unresolved act no longer parks a question, so this
|
||
// goes through applyAction, which is the only way an act runs.
|
||
func TestActOffAllowlistIsStillRefused(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st, _ := newClarifyHandler(t)
|
||
marker := filepath.Join(t.TempDir(), "not-allowed-ran")
|
||
if err := st.EnableTool(ctx, "uptime", []string{"true"}, false, "test", h.now()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
reply := h.applyAction(ctx, router.Decision{
|
||
Utterance: "rm " + marker,
|
||
Intent: router.IntentAct,
|
||
Slots: router.Slots{Fn: "rm " + marker, HasFn: true},
|
||
})
|
||
if strings.Contains(reply, "готово") {
|
||
t.Fatalf("an act that is not on the allowlist must not report success: %q", reply)
|
||
}
|
||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||
t.Fatalf("a clarified act off the allowlist ran anyway: %v", err)
|
||
}
|
||
if tools, err := st.ListTools(ctx, "enabled"); err != nil || len(tools) != 1 {
|
||
t.Fatalf("an act must not enable a tool: tools=%+v err=%v", tools, err)
|
||
}
|
||
}
|
||
|
||
// TestDestructiveActStillNeedsConfirm — the confirm gate stands on the act path.
|
||
func TestDestructiveActStillNeedsConfirm(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st, _ := newClarifyHandler(t)
|
||
marker := filepath.Join(t.TempDir(), "destructive-ran")
|
||
if err := st.EnableTool(ctx, "delete_backups", []string{"touch", marker}, true, "test", h.now()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
reply := h.applyAction(ctx, router.Decision{
|
||
Utterance: "delete_backups",
|
||
Intent: router.IntentAct,
|
||
Slots: router.Slots{Fn: "delete_backups", HasFn: true},
|
||
})
|
||
if !strings.Contains(reply, "да") || h.pending == nil {
|
||
t.Fatalf("a destructive act must park a confirm: reply=%q pending=%+v", reply, h.pending)
|
||
}
|
||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||
t.Fatalf("a destructive act ran before confirmation: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestNoQuestionWhenNothingIsMissing — noise keeps the canned reply, so she
|
||
// never invents a question for nothing.
|
||
func TestNoQuestionWhenNothingIsMissing(t *testing.T) {
|
||
h, _, _ := newClarifyHandler(t)
|
||
for _, dec := range []router.Decision{
|
||
clarifyDec(router.IntentChat, router.Slots{Text: "эм"}, "эм"),
|
||
clarifyDec(router.IntentQuery, router.Slots{Text: "ммм"}, "ммм"),
|
||
clarifyDec(router.IntentNote, router.Slots{Text: "..."}, "..."),
|
||
} {
|
||
if question, asked := h.askClarify(context.Background(), dec); asked {
|
||
t.Fatalf("intent %s should keep the canned reply, got %q", dec.Intent, question)
|
||
}
|
||
}
|
||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||
t.Fatal("noise must not park a question")
|
||
}
|
||
}
|
||
|
||
// TestClarifyExpiryIsAnnouncedAndWordsStillRoute — his answer lands after the
|
||
// TTL: she must say the old request is gone AND still answer the new words.
|
||
func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
|
||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
|
||
h, _, now := newClarifyHandler(t)
|
||
emb := router.NewHashEmbedder(1024)
|
||
h.recall.embedder = emb
|
||
h.router = buildRouter(emb, h.matcher, 0.55, nil)
|
||
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||
t.Fatal("expected a question")
|
||
}
|
||
*now = now.Add(clarifyTTL + time.Second)
|
||
|
||
reply := h.handleText(ctx, "", "как дела")
|
||
if !isClarifyExpired(reply) {
|
||
t.Fatalf("expired question must be announced first, got %q", reply)
|
||
}
|
||
if trimClarifyExpired(reply) == "" {
|
||
t.Fatalf("the new words must still be answered, got only the notice: %q", reply)
|
||
}
|
||
if h.clarifyStore.Get(textDialogueID, h.now()) != nil {
|
||
t.Fatal("the expired question must be gone")
|
||
}
|
||
// The notice is said once, not on every later utterance.
|
||
if reply := h.handleText(ctx, "", "как дела"); isClarifyExpired(reply) {
|
||
t.Fatalf("notice repeated on a later turn: %q", reply)
|
||
}
|
||
}
|
||
|
||
// TestNoPendingQuestionFallsThrough — with nothing parked, an utterance routes
|
||
// normally.
|
||
func TestNoPendingQuestionFallsThrough(t *testing.T) {
|
||
h, _, _ := newClarifyHandler(t)
|
||
if reply, handled := h.resolveClarifyAnswer(context.Background(), "напомни в 11:00"); handled {
|
||
t.Fatalf("no open question ⇒ must not be treated as an answer, got %q", reply)
|
||
}
|
||
}
|
||
|
||
// TestClarifyAsksAboutTheSecondGapToo — "напомни" with neither a subject nor a
|
||
// time. She asks about the subject, he gives it, and the request is still not
|
||
// complete. The old code handed applyAction a reminder with no time, which
|
||
// answered with a parse error for a question she never asked.
|
||
func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st, _ := newClarifyHandler(t)
|
||
|
||
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни"))
|
||
if !asked || question != "О чём напомнить?" {
|
||
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
||
}
|
||
|
||
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
||
if !handled {
|
||
t.Fatal("the answer must be consumed as an answer")
|
||
}
|
||
// Second gap, second attempt, so it is the second wording of the time
|
||
// question — the attempt budget is shared between the two paths.
|
||
want, _ := clarifyQuestionFor(dialogue.SlotTime, 2)
|
||
if reply != want {
|
||
t.Fatalf("a filled subject with no time must ask about the time as %q, got %q", want, reply)
|
||
}
|
||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||
if q == nil {
|
||
t.Fatal("the second gap must leave a question armed")
|
||
}
|
||
if q.Slots.Text == "" {
|
||
t.Fatalf("the re-parked question lost the answered subject: %+v", q.Slots)
|
||
}
|
||
|
||
if reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00"); !handled || reply == clarifyGaveUp {
|
||
t.Fatalf("the time answer must complete the reminder, handled=%v reply=%q", handled, reply)
|
||
}
|
||
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||
if err != nil || len(reminders) != 1 {
|
||
t.Fatalf("expected one reminder: %v err=%v", reminders, err)
|
||
}
|
||
if !strings.Contains(reminders[0].Payload, "маме") {
|
||
t.Fatalf("the reminder lost the subject: %q", reminders[0].Payload)
|
||
}
|
||
}
|
||
|
||
// TestClarifySecondGapRespectsTheAttemptCap — the second gap spends a question
|
||
// out of the same budget, so it cannot turn a capped exchange into an endless
|
||
// one. With one attempt allowed she acts on what she has instead of asking.
|
||
func TestClarifySecondGapRespectsTheAttemptCap(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, _, _ := newClarifyHandler(t)
|
||
h.clarifyMaxAttempts = 1
|
||
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked {
|
||
t.Fatal("expected the subject question")
|
||
}
|
||
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
||
if !handled {
|
||
t.Fatal("the answer must be consumed")
|
||
}
|
||
if reply == "Когда?" {
|
||
t.Fatal("out of attempts she must not ask a second question")
|
||
}
|
||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||
t.Fatal("no question may stay armed past the cap")
|
||
}
|
||
}
|
||
|
||
// TestClarifyProseHoldsThePersona — these lines are hand-written Russian that
|
||
// the phrasing eval never sees, because they never go through the phraser. They
|
||
// carry feminine self-reference ("ждала", "отпустила") and address him with a
|
||
// plain imperative, and they are exactly the kind of string someone later edits
|
||
// reaching for a synonym. Run the eval's own persona checks over them here.
|
||
func TestClarifyProseHoldsThePersona(t *testing.T) {
|
||
// Only the persona checks. Length and on-topic do not apply: these are not
|
||
// nudges, they have no rule to be on topic about, and the expiry lines are
|
||
// deliberately longer than a nudge ceiling.
|
||
want := map[string]bool{
|
||
eval.CheckFeminine: true,
|
||
eval.CheckHisGender: true,
|
||
eval.CheckAddress: true,
|
||
eval.CheckCringe: true,
|
||
}
|
||
lines := append([]string{clarifyGaveUp}, clarifyExpiredVariants...)
|
||
lines = append(lines, clarifyMissedVariants...)
|
||
for _, variants := range clarifyQuestionVariants {
|
||
lines = append(lines, variants...)
|
||
}
|
||
for _, line := range lines {
|
||
for _, r := range eval.RunChecks(eval.Case{}, line, "neutral") {
|
||
// The apology clause of the cringe check is scoped to nudges: it
|
||
// exists because apologising for a greenlit nudge undermines it.
|
||
// These lines are the opposite case. She did not understand him, or
|
||
// she let his request go, and "прости" there is ordinary speech
|
||
// rather than grovelling. Every other cringe rule still applies:
|
||
// pet names, emoji, exclamations, fake concern, praise.
|
||
// checkCringe returns the first break it finds, so this skip also
|
||
// hides a later one in the same line. Kept narrow on purpose: it
|
||
// only fires on a leading "apology (…)" detail.
|
||
if r.Name == eval.CheckCringe && strings.HasPrefix(r.Detail, "apology") {
|
||
continue
|
||
}
|
||
if want[r.Name] && !r.Pass {
|
||
t.Errorf("%q fails %s: %s", line, r.Name, r.Detail)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestExpiryNoticeSurvivesAConfirmTurn — she asks a question, he walks off, the
|
||
// question expires, he comes back and answers a confirm that is still parked.
|
||
// The confirm turn used to return before the notice was even computed, so he
|
||
// answered the confirm and never heard that the older request was let go.
|
||
func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
|
||
h, _, now := newClarifyHandler(t)
|
||
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||
t.Fatal("expected a question")
|
||
}
|
||
// A confirm parked with a longer life than the question, so only the
|
||
// question is stale when he speaks.
|
||
h.pending = &pendingAct{fn: "delete_backups", phrase: "удалить бэкапы", expiry: now.Add(time.Hour)}
|
||
*now = now.Add(clarifyTTL + time.Second)
|
||
|
||
reply := h.handleText(ctx, "", "нет")
|
||
if !isClarifyExpired(reply) {
|
||
t.Fatalf("the expired question must be announced on a confirm turn too, got %q", reply)
|
||
}
|
||
if trimClarifyExpired(reply) == "" {
|
||
t.Fatalf("the confirm answer must survive the notice, got only the notice: %q", reply)
|
||
}
|
||
if h.pending != nil {
|
||
t.Fatal("the confirm must still have been consumed")
|
||
}
|
||
if h.clarifyStore.Get(textDialogueID, h.now()) != nil {
|
||
t.Fatal("the expired question must be gone")
|
||
}
|
||
}
|
||
|
||
// The other half of the subject question: his answer must fill the empty slot,
|
||
// not replace the request. Slots.Text used to be the whole raw utterance for
|
||
// every intent, so the branch that fills a text slot could only ever overwrite
|
||
// (Vikunja #383). Here the parked request holds the hour and the answer holds
|
||
// what to say at it, and the reminder that lands has both.
|
||
func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st, _ := newClarifyHandler(t)
|
||
at := h.now().Add(2 * time.Hour)
|
||
|
||
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder,
|
||
router.Slots{Time: at, HasTime: true}, "напомни в 11"))
|
||
if !asked || question != "О чём напомнить?" {
|
||
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
||
}
|
||
|
||
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
||
if !handled {
|
||
t.Fatal("the answer to an open question must be consumed as an answer")
|
||
}
|
||
if reply == clarifyGaveUp {
|
||
t.Fatalf("a good answer must not drop the request: %q", reply)
|
||
}
|
||
|
||
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||
if err != nil || len(reminders) != 1 {
|
||
t.Fatalf("clarified reminder was not created: reminders=%v err=%v", reminders, err)
|
||
}
|
||
if !strings.Contains(reminders[0].Payload, "маме") {
|
||
t.Fatalf("the answer never reached the reminder: %q", reminders[0].Payload)
|
||
}
|
||
// The hour is the fire time, not a word in the body: the body is what she
|
||
// says at the hour, and the time expression is stripped out of it
|
||
// (Vikunja #469). Clobbering the parked request would show up here as a
|
||
// reminder that fires at some other time than the one he asked for.
|
||
if got := reminders[0].FireTs.UTC(); !got.Equal(at.UTC()) {
|
||
t.Fatalf("the answer clobbered the original request: fires at %v, want %v", got, at.UTC())
|
||
}
|
||
}
|
||
|
||
// TestClarifyIsPerConversation — the parked question belongs to the reach that
|
||
// was asked. Before this the clarify store had one global key, so a question
|
||
// asked in the web chat and never answered captured the next utterance from
|
||
// telegram, or from the mic, and answered it against a request the speaker had
|
||
// never made (Vikunja #466).
|
||
func TestClarifyIsPerConversation(t *testing.T) {
|
||
h, _, _ := newClarifyHandler(t)
|
||
web := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||
telegram := withDialogueID(context.Background(), dialogueIDFor(sourceText, "telegram:42"))
|
||
|
||
if _, asked := h.askClarify(web, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||
t.Fatal("expected a question on the web conversation")
|
||
}
|
||
if _, handled := h.resolveClarifyAnswer(telegram, "в 11:00"); handled {
|
||
t.Fatal("a question asked on the web must not eat a telegram utterance")
|
||
}
|
||
if _, handled := h.resolveClarifyAnswer(voiceCtx(), "в 11:00"); handled {
|
||
t.Fatal("a question asked on the web must not eat what he says at the mic")
|
||
}
|
||
if reply, handled := h.resolveClarifyAnswer(web, "в 11:00"); !handled || reply == clarifyGaveUp {
|
||
t.Fatalf("the asker's own answer must land, handled=%v reply=%q", handled, reply)
|
||
}
|
||
}
|
||
|
||
// voiceCtx — the mic's conversation, which carries no id of its own.
|
||
func voiceCtx() context.Context {
|
||
return withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
|
||
}
|
||
|
||
// TestARestartExpiresTheParkedQuestion pins the Vikunja #385 decision: the
|
||
// question dies with the process, and she does not claim to have let it go —
|
||
// the words that follow are routed as a fresh request. Restarting is modelled
|
||
// the way the daemon does it, by building a second handler over the same store.
|
||
func TestARestartExpiresTheParkedQuestion(t *testing.T) {
|
||
h, _, _ := newClarifyHandler(t)
|
||
ctx := voiceCtx()
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||
t.Fatal("expected a question before the restart")
|
||
}
|
||
|
||
restarted, _, _ := newClarifyHandler(t)
|
||
if _, handled := restarted.resolveClarifyAnswer(ctx, "в 11:00"); handled {
|
||
t.Fatal("a question parked before the restart must not eat the next utterance")
|
||
}
|
||
if notice := restarted.clarifyExpiredNotice(ctx); notice != "" {
|
||
t.Fatalf("notice = %q, want silence: nothing survived to expire", notice)
|
||
}
|
||
}
|
||
|
||
// TestClarifyStepsAsideForItsOwnRequest — Vikunja #554. An act she could not
|
||
// fulfil parked "Что сделать?", and the three turns after it were scored as
|
||
// answers to that question: a world question, then "как дела", then the give-up
|
||
// line. None of them was ever an answer.
|
||
func TestClarifyStepsAsideForItsOwnRequest(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, _, _ := newClarifyHandler(t)
|
||
|
||
// A reminder, not the act this bug was found on: since Vikunja #556 an act
|
||
// no longer parks anything, so it can no longer eat the turn after it.
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||
t.Fatal("a reminder with no time should be asked about")
|
||
}
|
||
if reply, handled := h.resolveClarifyAnswer(ctx, "кто изобрёл телефон"); handled {
|
||
t.Fatalf("a world question must route as itself, got %q", reply)
|
||
}
|
||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||
t.Error("the parked question must be dropped, not left to eat the turn after this one")
|
||
}
|
||
}
|
||
|
||
// TestClarifyStillRetriesOnAnAnswerThatMissed — the other half of #554, and the
|
||
// reason the test above is narrow. A bare noun answers nothing either, but it
|
||
// carries no request of its own, so she asks again as before.
|
||
func TestClarifyStillRetriesOnAnAnswerThatMissed(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, _, _ := newClarifyHandler(t)
|
||
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||
t.Fatal("expected the time question")
|
||
}
|
||
reply, handled := h.resolveClarifyAnswer(ctx, "ага")
|
||
if !handled || reply == "" {
|
||
t.Fatalf("a missed answer must still be re-asked, handled=%v reply=%q", handled, reply)
|
||
}
|
||
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
||
t.Error("the question must survive a missed answer")
|
||
}
|
||
}
|
||
|
||
// TestClarifyQuestionShapedAnswerThatFillsTheGapStillLands — the guard runs only
|
||
// where nothing was filled. "во сколько?" is question-shaped and is also how a
|
||
// time gets said back, so an answer that closes the gap wins whatever its shape.
|
||
func TestClarifyQuestionShapedAnswerThatFillsTheGapStillLands(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st, _ := newClarifyHandler(t)
|
||
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||
t.Fatal("expected the time question")
|
||
}
|
||
if reply, handled := h.resolveClarifyAnswer(ctx, "а что если в 11:00"); !handled || reply == clarifyGaveUp {
|
||
t.Fatalf("an answer that fills the gap must land, handled=%v reply=%q", handled, reply)
|
||
}
|
||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 1 {
|
||
t.Fatalf("reminder was not created: reminders=%v err=%v", reminders, err)
|
||
}
|
||
}
|
||
|
||
// TestUnresolvedActSaysItDoesNotKnowTheCommand — Vikunja #556. "Что сделать?"
|
||
// has no answer he can give, so an act that matched no capability is refused in
|
||
// one line and nothing is parked. It does not recite what she can do instead.
|
||
func TestUnresolvedActSaysItDoesNotKnowTheCommand(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st, _ := newClarifyHandler(t)
|
||
// Enabled tools change nothing here: this act matched none of them.
|
||
if err := st.EnableTool(ctx, "uptime", []string{"true"}, false, "test", h.now()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
reply, spoken := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "выключи свет"}, "выключи свет"))
|
||
if !spoken || reply != actNotRecognized {
|
||
t.Fatalf("reply = %q spoken=%v, want %q", reply, spoken, actNotRecognized)
|
||
}
|
||
if strings.Contains(reply, "uptime") {
|
||
t.Errorf("reply = %q, want no list of capabilities he did not ask about", reply)
|
||
}
|
||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||
t.Error("nothing to ask about, so nothing may be parked")
|
||
}
|
||
}
|
||
|
||
|
||
|
||
// newRoutingClarifyHandler wires the real cascade (hash embedder, no model) onto
|
||
// the clarify handler, so a test can drive handleText end to end and see which
|
||
// gate claimed the turn.
|
||
func newRoutingClarifyHandler(t *testing.T) (*reactiveHandler, *store.Store) {
|
||
t.Helper()
|
||
h, st, _ := newClarifyHandler(t)
|
||
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil)
|
||
h.recall = recallWiring{embedder: router.NewHashEmbedder(1024), memStore: memory.NewInMemoryStore()}
|
||
return h, st
|
||
}
|
||
|
||
// TestIncompleteReminderAsksInsteadOfFailing — Vikunja #557. "напомни позвонить"
|
||
// is routed confidently and is still half a request. It used to reach applyAction,
|
||
// fail on the missing time and park nothing, so the "в семь вечера" that followed
|
||
// was routed as a world question and web-searched.
|
||
func TestIncompleteReminderAsksInsteadOfFailing(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st := newRoutingClarifyHandler(t)
|
||
|
||
reply := h.handleText(ctx, "web", "напомни позвонить маме")
|
||
want, _ := clarifyQuestionFor(dialogue.SlotTime, 1)
|
||
if reply != want {
|
||
t.Fatalf("reply = %q, want the time question %q", reply, want)
|
||
}
|
||
if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) == nil {
|
||
t.Fatal("the request must be parked, or the answer has nowhere to land")
|
||
}
|
||
if reply := h.handleText(ctx, "web", "в семь вечера"); strings.Contains(reply, "нашла") {
|
||
t.Fatalf("the answer to her own question must not be looked up: %q", reply)
|
||
}
|
||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 1 {
|
||
t.Fatalf("the answer did not complete the reminder: reminders=%v err=%v", reminders, err)
|
||
}
|
||
}
|
||
|
||
// TestBareCaptureVerbAsksWhatToRecord — the other half of #557. A bare "запиши"
|
||
// went to the resident model as chat, which agreed to a wording change nobody
|
||
// asked for. It is a fact with no key, and that gap has a question.
|
||
func TestBareCaptureVerbAsksWhatToRecord(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, _ := newRoutingClarifyHandler(t)
|
||
|
||
reply := h.handleText(ctx, "web", "запиши")
|
||
want, _ := clarifyQuestionFor(dialogue.SlotKey, 1)
|
||
if reply != want {
|
||
t.Fatalf("reply = %q, want %q", reply, want)
|
||
}
|
||
if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) == nil {
|
||
t.Fatal("the request must be parked so the next utterance completes it")
|
||
}
|
||
}
|
||
|
||
// TestACompleteTurnStillDoesNotAsk — the gate reads a missing slot, not any
|
||
// slot, so a request she can act on must never turn into a question. Checked on
|
||
// the decision rather than through the cascade: what is at stake is the gate's
|
||
// condition, and driving it through the hash embedder would measure routing.
|
||
func TestACompleteTurnStillDoesNotAsk(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, _, _ := newClarifyHandler(t)
|
||
|
||
complete := []router.Decision{
|
||
{Intent: router.IntentReminder, Slots: router.Slots{Text: "позвонить маме", HasTime: true}, Utterance: "напомни в 11 позвонить маме"},
|
||
{Intent: router.IntentFact, Slots: router.Slots{Key: "water", Value: "выпил", HasKey: true}, Utterance: "я выпил воды"},
|
||
{Intent: router.IntentNote, Slots: router.Slots{Text: "купить хлеб"}, Utterance: "запиши купить хлеб"},
|
||
{Intent: router.IntentQuery, Slots: router.Slots{Text: "что у меня сегодня"}, Utterance: "что у меня сегодня"},
|
||
}
|
||
for _, dec := range complete {
|
||
if gaps := missingFor(dec); len(gaps) > 0 {
|
||
t.Errorf("%q reads as incomplete: %v", dec.Utterance, gaps)
|
||
}
|
||
if reply, asked := h.askClarify(ctx, dec); asked {
|
||
t.Errorf("%q was answered with a question: %q", dec.Utterance, reply)
|
||
}
|
||
}
|
||
}
|