clarify: ask about the second missing slot instead of failing on it

wantedSlots says a reminder needs both a subject and a time, but askClarify
parks only the first gap, because she asks about one thing per turn. When both
were missing the second gap was never revisited. "напомни" with no subject and
no time asked "О чём напомнить?", accepted "позвонить маме", then handed
applyAction a reminder with no time, which answered "не получилось разобрать
время напоминания." That is a parse error for a question she never asked.

A filled gap now re-enters the clarify loop for whatever wantedSlots still
names, one question per turn as before, spending the same attempt budget so the
exchange stays bounded. The answered subject is also folded into the raw
utterance, because actionReminder stores the utterance as the payload and a
reminder clarified out of a bare "напомни" would otherwise fire saying nothing.

Found in review of #50.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
This commit is contained in:
kami
2026-08-01 14:04:23 +04:00
parent 7f42cc73be
commit f891a81ab2
2 changed files with 133 additions and 4 deletions
+63
View File
@@ -330,3 +330,66 @@ func TestNoPendingQuestionFallsThrough(t *testing.T) {
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(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")
}
if reply != "Когда?" {
t.Fatalf("a filled subject with no time must ask about the time, got %q", 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(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")
}
}