router: a reminder with no subject asks instead of guessing (V-383)
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.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user