From 6d3f5b5b01dc1b3cbda4dc749f1555acafd37963 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 02:49:02 +0400 Subject: [PATCH] router: a reminder with no subject asks instead of guessing (V-383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slots.Text was the raw utterance for every intent, so a reminder could not have an empty subject. StillMissing never reported SlotText, the question "О чём напомнить?" was unaskable, and the branch in PendingQuestion.Answer that fills a text slot could only overwrite the whole request. The LLM path now keeps the model's own text, empty included, and the gate turns a subjectless reminder into a question. The classifier path is unchanged: it has no subject parser, so the utterance is the only signal it has. --- cmd/mavend/clarify_test.go | 36 +++++++++++++++++++++++++++++++ internal/router/llmrouter.go | 6 +++++- internal/router/llmrouter_test.go | 32 +++++++++++++++++++++++++++ internal/router/router.go | 16 +++++++++++++- 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/cmd/mavend/clarify_test.go b/cmd/mavend/clarify_test.go index e07298a..8b377e1 100644 --- a/cmd/mavend/clarify_test.go +++ b/cmd/mavend/clarify_test.go @@ -465,3 +465,39 @@ func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) { 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(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) + } + if !strings.Contains(reminders[0].Payload, "11") { + t.Fatalf("the answer clobbered the original request: %q", reminders[0].Payload) + } +} diff --git a/internal/router/llmrouter.go b/internal/router/llmrouter.go index 5b88593..341193b 100644 --- a/internal/router/llmrouter.go +++ b/internal/router/llmrouter.go @@ -210,7 +210,11 @@ func (lr *LLMRouter) Route(ctx context.Context, utterance string, now time.Time) d.Slots.HasKey = a.Key != "" case IntentReminder: d.Intent = IntentReminder - d.Slots.Text = firstNonEmpty(a.Text, utterance) + // No utterance fallback here, unlike every other intent below. The + // model returning no text for a reminder means it found no subject, + // and "напомни в 11" is not a subject. Leaving Text empty is what + // lets the gate turn that into a question (Vikunja #383). + d.Slots.Text = a.Text case IntentNote: d.Intent = IntentNote d.Slots.Text = firstNonEmpty(a.Text, utterance) diff --git a/internal/router/llmrouter_test.go b/internal/router/llmrouter_test.go index 49279c1..f82e016 100644 --- a/internal/router/llmrouter_test.go +++ b/internal/router/llmrouter_test.go @@ -356,3 +356,35 @@ func TestRouterLLMFactWithResolvedKeyStaysConfident(t *testing.T) { t.Fatalf("a fact the parser could key must not clarify: %+v", d) } } + +// A reminder with a time and no subject must come back empty and gated, not +// backfilled with the raw words. "напомни в 11" carries an hour and nothing to +// say at that hour; parking the utterance in Text made the request look +// complete, so the daemon set a reminder that fires saying "напомни в 11" +// (Vikunja #383). +func TestLLMReminderWithoutSubjectAsksInsteadOfGuessing(t *testing.T) { + r := newLLMTestRouter(t, `{"intent":"reminder"}`) + d, err := r.Route(context.Background(), "напомни в 11", refNow()) + if err != nil { + t.Fatalf("route: %v", err) + } + if d.Slots.Text != "" { + t.Fatalf("subject backfilled from the utterance: %q", d.Slots.Text) + } + if !d.Clarify { + t.Fatalf("a subjectless reminder was accepted, confidence %v", d.Confidence) + } +} + +// The gate is about the subject, not about reminders in general: one that has +// both halves still runs without a question. +func TestLLMReminderWithSubjectIsNotGated(t *testing.T) { + r := newLLMTestRouter(t, `{"intent":"reminder","text":"позвонить маме"}`) + d, err := r.Route(context.Background(), "напомни в 11 позвонить маме", refNow()) + if err != nil { + t.Fatalf("route: %v", err) + } + if d.Clarify { + t.Fatalf("a complete reminder was sent back as a question: %+v", d.Slots) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index bf9b430..1df278c 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -147,7 +147,15 @@ func (r *Router) fillSlots(ctx context.Context, d *Decision, now time.Time) { d.Slots.Fn, d.Slots.Args, d.Slots.HasFn = fn, args, true } } - if d.Slots.Text == "" { + // The extractor's Text is the raw utterance, which is the payload for a + // note, a query or a chat turn but not for a reminder — there Text is the + // subject, what she says at the hour. Backfilling it made Text impossible + // to be empty, so StillMissing never reported SlotText and "О чём + // напомнить?" was unaskable; the answer to a question she did manage to + // ask then overwrote the whole request instead of filling one gap + // (Vikunja #383). A reminder with no subject stays empty and is gated + // below into a question. + if d.Slots.Text == "" && d.Intent != IntentReminder { d.Slots.Text = ex.Text } // Stage stays 1: it says who decided the route, and that was the LLM. @@ -177,6 +185,12 @@ func (r *Router) gateLLMDecision(d *Decision) { if d.Intent == IntentAct && !d.Slots.HasFn && d.Confidence > llmThinConfidence { d.Confidence = llmThinConfidence } + // A reminder with no subject: she knows when but not what to say then. + // Setting it anyway fires an empty reminder at the hour, which reads as a + // bug to him and cannot be repaired after the fact. Ask (Vikunja #383). + if d.Intent == IntentReminder && d.Slots.Text == "" && d.Confidence > llmThinConfidence { + d.Confidence = llmThinConfidence + } if d.Confidence < r.threshold { d.Clarify = true }