From cb0a3a4f20ea04c121117d03d5f7a43a810187e7 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 00:56:06 +0400 Subject: [PATCH] voice: tests for the turn role and the Rome pair (V-560) The measured failure of 2026-08-05 end to end through the real cascade, plus the content test the classifier rests on, the call-off, the cost bound, and the persona checks over the two new lines. --- cmd/mavend/clarify_test.go | 2 +- cmd/mavend/turnrole_test.go | 233 ++++++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 cmd/mavend/turnrole_test.go diff --git a/cmd/mavend/clarify_test.go b/cmd/mavend/clarify_test.go index 85a9df6..ac084a5 100644 --- a/cmd/mavend/clarify_test.go +++ b/cmd/mavend/clarify_test.go @@ -419,7 +419,7 @@ func TestClarifyProseHoldsThePersona(t *testing.T) { eval.CheckAddress: true, eval.CheckCringe: true, } - lines := append([]string{clarifyGaveUp}, clarifyExpiredVariants...) + lines := append([]string{clarifyGaveUp, clarifyCancelled, clarifyDropped}, clarifyExpiredVariants...) lines = append(lines, clarifyMissedVariants...) for _, variants := range clarifyQuestionVariants { lines = append(lines, variants...) diff --git a/cmd/mavend/turnrole_test.go b/cmd/mavend/turnrole_test.go new file mode 100644 index 0000000..88fdf71 --- /dev/null +++ b/cmd/mavend/turnrole_test.go @@ -0,0 +1,233 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/dialogue" + "github.com/kami/maven/internal/router" +) + +// TestOwnContentSeparatesAValueFromAQuestion pins the test the whole role +// classifier rests on: after the frame, the numbers and the closed time sets +// come out, does anything of his own remain? A hedged time leaves nothing. A +// question about the weather leaves the weather. +func TestOwnContentSeparatesAValueFromAQuestion(t *testing.T) { + cases := []struct { + text string + own bool + }{ + {"в 11:00", false}, + {"в семь вечера", false}, + {"нет, в 15:00", false}, + {"а что если в 11:00", false}, + {"на 9", false}, + {"а, да, прости — на 9", false}, + {"завтра", false}, + {"в половине восьмого", false}, + {"какая сейчас погода в Риме?", true}, + {"кто изобрёл телефон", true}, + {"напомни в 11:00", true}, + {"позвонить маме", true}, + {"запиши что я пил воду", true}, + } + for _, tc := range cases { + if got := len(ownContent(tc.text)) > 0; got != tc.own { + t.Errorf("ownContent(%q) = %v, want own content = %v", tc.text, ownContent(tc.text), tc.own) + } + } +} + +// TestCancelIsTheWholeUtterance — a call-off calls the request off, and a +// sentence that merely contains the word does not. +func TestCancelIsTheWholeUtterance(t *testing.T) { + for _, yes := range []string{"отмена", "забудь", "неважно", "проехали", "cancel", "ой, отмена"} { + if !isCancel(yes) { + t.Errorf("isCancel(%q) = false, want true", yes) + } + } + for _, no := range []string{"забудь купить молоко", "в 11:00", "позвонить маме", ""} { + if isCancel(no) { + t.Errorf("isCancel(%q) = true, want false", no) + } + } +} + +// TestTurnRoleReadsTheRoutedDecision — the inversion itself. The same utterance +// gets a different role depending on what the router made of it, which is the +// evidence the old guard never had. +func TestTurnRoleReadsTheRoutedDecision(t *testing.T) { + q := &dialogue.PendingQuestion{ + Intent: dialogue.Intent(router.IntentReminder), + Missing: []dialogue.Slot{dialogue.SlotTime}, + } + dec := func(in router.Intent, s router.Slots) router.Decision { + return router.Decision{Intent: in, Slots: s} + } + cases := []struct { + name string + text string + routed router.Decision + ok bool + answer dialogue.Slots + want turnRole + }{ + { + // The measured defect. The extractor finds "сейчас" and would have + // closed the gap with it; the route says this is a question of its + // own, and the question wins. + name: "a world question mid-flow is a side query", + text: "какая сейчас погода в Риме?", + routed: dec(router.IntentQuery, router.Slots{Text: "какая сейчас погода в Риме?"}), + ok: true, + answer: dialogue.Slots{HasTime: true, Time: time.Now()}, + want: roleSideQuery, + }, + { + name: "a hedged time is an answer even routed as a query", + text: "а что если в 11:00", + routed: dec(router.IntentQuery, router.Slots{Text: "а что если в 11:00"}), + ok: true, + answer: dialogue.Slots{HasTime: true, Time: time.Now()}, + want: roleAnswer, + }, + { + name: "a fresh reminder is a new request", + text: "напомни завтра позвонить маме", + routed: dec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}), + ok: true, + want: roleNewRequest, + }, + { + name: "a capture is a new request", + text: "запиши что я пил воду", + routed: dec(router.IntentFact, router.Slots{Key: "water", HasKey: true}), + ok: true, + want: roleNewRequest, + }, + { + name: "an act that resolved to a capability is a new request", + text: "выключи свет в спальне", + routed: dec(router.IntentAct, router.Slots{Fn: "light_off", HasFn: true}), + ok: true, + want: roleNewRequest, + }, + { + // She could not route it. An utterance she did not understand does + // not outrank the question in front of it. + name: "a clarify decision is not a request of its own", + text: "выключи свет", + routed: router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "light_off", HasFn: true}, Clarify: true}, + ok: true, + want: roleAnswer, + }, + { + name: "a bare noun that answers nothing is still an answer", + text: "ага", + ok: false, + want: roleAnswer, + }, + { + name: "no route to read falls back to the shape", + text: "кто изобрёл телефон", + ok: false, + want: roleSideQuery, + }, + { + name: "a call-off needs no route at all", + text: "отмена", + ok: false, + want: roleCancel, + }, + } + for _, tc := range cases { + if got := classifyTurnRole(q, tc.text, tc.answer, tc.routed, tc.ok); got != tc.want { + t.Errorf("%s: classifyTurnRole(%q) = %s, want %s", tc.name, tc.text, got, tc.want) + } + } +} + +// TestTurnRoleNamesACorrection — the answer overwrites a slot she was not +// asking about. Handled like an answer, named as what it is. +func TestTurnRoleNamesACorrection(t *testing.T) { + nine := time.Date(2026, 8, 6, 9, 0, 0, 0, time.UTC) + q := &dialogue.PendingQuestion{ + Intent: dialogue.Intent(router.IntentReminder), + Missing: []dialogue.Slot{dialogue.SlotText}, + Slots: dialogue.Slots{HasTime: true, Time: nine.Add(2 * time.Hour)}, + } + got := classifyTurnRole(q, "нет, на 9", dialogue.Slots{HasTime: true, Time: nine}, router.Decision{}, false) + if got != roleCorrection { + t.Fatalf("role = %s, want %s", got, roleCorrection) + } +} + +// TestRomeIsAnsweredAndTheReminderIsNotInvented — the measured failure of +// 2026-08-05, end to end through the real cascade. "напомни позвонить маме" +// parks the time question; the weather question that follows must not become +// its answer, must not create a reminder for a time nobody asked for, and must +// not be dropped in silence. +func TestRomeIsAnsweredAndTheReminderIsNotInvented(t *testing.T) { + ctx := context.Background() + h, st := newRoutingClarifyHandler(t) + + if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") { + t.Fatalf("expected the time question, got %q", reply) + } + reply := h.handleText(ctx, "web", "какая сейчас погода в Риме?") + if strings.Contains(reply, "напомню") { + t.Fatalf("the question was eaten as the reminder's time again: %q", reply) + } + if !strings.HasPrefix(reply, clarifyDropped) { + t.Fatalf("the parked request died without a word: %q", reply) + } + if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 { + t.Fatalf("a reminder was invented for a time nobody asked for: %v err=%v", reminders, err) + } + if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) != nil { + t.Fatal("the parked question must be gone, not left to eat the next turn") + } +} + +// TestClarifyCancelEndsTheExchange — "отмена" while she is waiting calls the +// half-built request off, out loud, and creates nothing. +func TestClarifyCancelEndsTheExchange(t *testing.T) { + ctx := context.Background() + h, st := newRoutingClarifyHandler(t) + + if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") { + t.Fatalf("expected the time question, got %q", reply) + } + if reply := h.handleText(ctx, "web", "отмена"); reply != clarifyCancelled { + t.Fatalf("reply = %q, want %q", reply, clarifyCancelled) + } + if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 { + t.Fatalf("a cancelled request still landed: %v err=%v", reminders, err) + } + if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) != nil { + t.Fatal("a cancelled exchange must leave nothing parked") + } +} + +// TestTheTurnIsRoutedOnce — the cost bound. A turn with a question parked pays +// for one extra route and not two: the clarify resolver and the pipeline read +// the same memo. +func TestTheTurnIsRoutedOnce(t *testing.T) { + h, _ := newRoutingClarifyHandler(t) + rt := h.newTurnRoute("какая сейчас погода в Риме?", h.now()) + ctx := withTurnRoute(withDialogueID(context.Background(), voiceDialogueID), rt) + + first, ok := h.routeForRole(ctx, rt.text) + if !ok { + t.Fatal("the cascade must produce a decision to classify against") + } + second, _, _, err := rt.resolve(ctx) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if second.Intent != first.Intent || second.Utterance != first.Utterance { + t.Fatalf("the pipeline routed again and got something else: %+v vs %+v", second, first) + } +}