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:
2026-08-06 00:56:06 +04:00
parent 6abd2768e8
commit cb0a3a4f20
2 changed files with 234 additions and 1 deletions
+1 -1
View File
@@ -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...)
+233
View File
@@ -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)
}
}