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.
This commit is contained in:
@@ -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...)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user