Files
Maven/cmd/mavend/turnrole_test.go
T
claude bac8673f05 a routed intent beats a frame match mid-flow (V-577)
Every token of "что у меня сегодня?" is frame, so ownContent left nothing,
needsRoute returned false and no route was computed at all. The parked reminder
then read "сегодня" as its time and the question was answered nowhere.

A question shape now gets routed even when it leaves no content of its own, and
a role that fills nothing she asked about is decided by the route. A statement
he makes mid-flow gets a role of its own, roleAside, so a note or a fact is
stored and the question comes back instead of being dropped in silence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 02:20:03 +04:00

282 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
},
{
// V-577 shape 1. Every token is frame, so the content gate called
// this an answer and the reminder took "сегодня" for its time. It
// fills nothing she asked about, so the route decides, and the route
// says the calendar answers it.
name: "an agenda question of pure frame words is a side query",
text: "что у меня сегодня?",
routed: dec(router.IntentQuery, router.Slots{}),
ok: true,
want: roleSideQuery,
},
{
// V-577 shape 2. Neither a slot value nor a request nor a cancel.
// It was dropped in silence; it is an aside, and an aside is stored
// and re-asked.
name: "a fact stated mid-flow is an aside",
text: "у меня новый ноутбук",
routed: dec(router.IntentNote, router.Slots{Text: "у меня новый ноутбук"}),
ok: true,
want: roleAside,
},
{
// A route she is not sure of is not evidence that he stated
// anything, and "позвонить маме" is the answer to the other half of
// a reminder.
name: "an unsure note is not an aside",
text: "позвонить маме",
routed: router.Decision{Intent: router.IntentNote, 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 and must not create a reminder for a time nobody asked for.
//
// V-560 got that far by DROPPING the parked request and saying so, and the
// owner rejected the notice on sight: he did not ask to lose the reminder. So
// the contract here is V-561's — the flow is suspended, this turn's reply ends
// with the question coming back, and nothing says anything was let go.
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.Contains(reply, clarifyDropped) {
t.Fatalf("a side query suspends the flow; nothing was dropped, so nothing may say so: %q", reply)
}
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
if !strings.HasSuffix(reply, resumed) {
t.Fatalf("the reply must end with the resumed question %q, got %q", resumed, 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)
}
// Still parked, and still on its first attempt: he answered the side query,
// not this question, so no retry may have been spent on it.
q := h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now())
if q == nil {
t.Fatal("the parked question was dropped instead of suspended")
}
if q.Attempts != 1 {
t.Fatalf("the side query spent a clarify attempt: attempts = %d, want 1", q.Attempts)
}
if !strings.Contains(q.Utterance, "маме") {
t.Fatalf("the suspended request lost what it was about: %q", q.Utterance)
}
}
// 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)
}
}