cb0a3a4f20
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.
234 lines
8.6 KiB
Go
234 lines
8.6 KiB
Go
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)
|
||
}
|
||
}
|