package main import ( "context" "strings" "testing" "time" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" ) func TestParseRepairReadsTheCorrectedIntent(t *testing.T) { cases := []struct { utterance string want router.Intent ok bool }{ {"нет, ты не поняла, это заметка", router.IntentNote, true}, {"нет, это заметка", router.IntentNote, true}, {"это не напоминание, а заметка", router.IntentNote, true}, {"это заметка, а не напоминание", router.IntentNote, true}, {"ты не так поняла — это факт", router.IntentFact, true}, {"неправильно поняла, это был вопрос", router.IntentQuery, true}, {"you got it wrong, that was a note", router.IntentNote, true}, // No marker: an ordinary request that happens to name an intent. {"запиши заметку купить хлеб", "", false}, {"напомни мне про заметку", "", false}, // A marker with no intent named: nothing to correct to. {"ты не так поняла", "", false}, {"", "", false}, } for _, c := range cases { got, _, ok := parseRepair(c.utterance) if ok != c.ok || (ok && got != c.want) { t.Errorf("parseRepair(%q) = %q,%v; want %q,%v", c.utterance, got, ok, c.want, c.ok) } } } // TestRepairTeachesTheClassifierAndRedoesTheTurn is the whole feature: the // previous utterance is filed under the intent he named, the classifier keeps // it as an example, and he hears that it landed. func TestRepairTeachesTheClassifierAndRedoesTheTurn(t *testing.T) { h, st, now := newClarifyHandler(t) emb := router.NewHashEmbedder(256) cls := router.NewClassifier(emb) h.recall.embedder = emb h.router = router.New(router.Config{Classifier: cls, Extractor: h.extractor}) ctx := context.Background() h.recordTurn("купить хлеб", router.IntentFact) reply, handled := h.resolveRepair(ctx, "нет, ты не поняла, это заметка") if !handled { t.Fatal("a spoken correction was not handled") } if !strings.Contains(reply, "заметка") { t.Errorf("the correction is not named out loud: %q", reply) } if strings.Contains(reply, "не вышло") { t.Errorf("learning failed unexpectedly: %q", reply) } ex := cls.Examples(router.IntentNote) if len(ex) != 1 || ex[0].Text != "купить хлеб" { t.Fatalf("the classifier did not learn the correction: %+v", ex) } notes, err := st.RecentNotes(ctx, 5) if err != nil { t.Fatalf("recent notes: %v", err) } if len(notes) != 1 || !strings.Contains(notes[0].Text, "купить хлеб") { t.Fatalf("the request was not redone as a note: %+v", notes) } _ = now } func TestRepairNeedsARecentTurnToPointAt(t *testing.T) { h, _, now := newClarifyHandler(t) h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))}) ctx := context.Background() // Nothing said yet. if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled { t.Error("a correction with no previous turn was handled") } // Said, but long ago. h.recordTurn("купить хлеб", router.IntentFact) *now = now.Add(repairWindow + time.Minute) if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled { t.Error("a correction outside the window was handled") } } func TestRepairIsSpentOnce(t *testing.T) { h, _, _ := newClarifyHandler(t) emb := router.NewHashEmbedder(256) h.recall.embedder = emb h.router = router.New(router.Config{Classifier: router.NewClassifier(emb), Extractor: h.extractor}) ctx := context.Background() h.recordTurn("купить хлеб", router.IntentFact) if _, handled := h.resolveRepair(ctx, "нет, это заметка"); !handled { t.Fatal("the first correction was not handled") } if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled { t.Error("the same turn was corrected twice") } } // TestRepairPassesWhenSheAlreadyDidThat — he names the intent she used. There // is nothing to teach and redoing it would file the request a second time. func TestRepairPassesWhenSheAlreadyDidThat(t *testing.T) { h, _, _ := newClarifyHandler(t) h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))}) h.recordTurn("купить хлеб", router.IntentNote) if _, handled := h.resolveRepair(context.Background(), "нет, это заметка"); handled { t.Error("a correction to the intent she already used was handled") } } // TestRepairIntentWordCollisions — the prefix list matched more than the word // (Vikunja #528). "команд" is inside "командировка" and "факт" inside // "фактически", and either one used to name an intent she would redo the turn // under. func TestRepairIntentWordCollisions(t *testing.T) { for _, s := range []string{ "нет, это про командировку", "нет, фактически всё нормально", } { if _, _, ok := parseRepair(s); ok { t.Errorf("parseRepair(%q) claimed a correction", s) } } // The declined forms the prefixes existed to cover still work, and the // negated half is still skipped. for _, tc := range []struct { utterance string want router.Intent }{ {"нет, это заметка", router.IntentNote}, {"ты не так поняла, это заметку надо было", router.IntentNote}, {"нет, это напоминание, а не заметка", router.IntentReminder}, {"нет, не напоминание, а заметка", router.IntentNote}, {"нет, это командой было", router.IntentAct}, } { got, _, ok := parseRepair(tc.utterance) if !ok || got != tc.want { t.Errorf("parseRepair(%q) = %q, %v; want %q, true", tc.utterance, got, ok, tc.want) } } } // V-636. A spoken correction lands in the same table the /chat gesture writes, // so the sample is not limited to the turns he happened to type. func TestSpokenCorrectionWritesTheLabel(t *testing.T) { h, st, _ := newClarifyHandler(t) emb := router.NewHashEmbedder(256) h.recall.embedder = emb h.router = router.New(router.Config{Classifier: router.NewClassifier(emb), Extractor: h.extractor}) ctx := context.Background() id, err := st.WriteRoutingTrace(ctx, store.RoutingTrace{ Ts: h.now(), Utterance: "купить хлеб", Intent: "fact", Source: "tap:voice", }) if err != nil { t.Fatal(err) } h.recordTurn("купить хлеб", router.IntentFact) h.stampLastTurn("купить хлеб", id) if _, handled := h.resolveRepair(ctx, "нет, это заметка"); !handled { t.Fatal("the correction was not handled") } labels, err := st.RoutingLabels(ctx, 5) if err != nil { t.Fatal(err) } if len(labels) != 1 || labels[0].Was != "fact" || labels[0].ShouldBe != "note" { t.Fatalf("labels %+v: the spoken correction did not land as a pair", labels) } } // The cheap half, which voice needs more than the web does: naming an intent // aloud means saying "заметка", which is her vocabulary and not his. func TestUntargetedSpokenCorrection(t *testing.T) { h, st, now := newClarifyHandler(t) ctx := context.Background() seed := func(utterance string) int64 { id, err := st.WriteRoutingTrace(ctx, store.RoutingTrace{ Ts: h.now(), Utterance: utterance, Intent: "query", Source: "tap:voice", }) if err != nil { t.Fatal(err) } h.recordTurn(utterance, router.IntentQuery) h.stampLastTurn(utterance, id) return id } seed("поужинал") reply, handled := h.resolveUntargetedRepair(ctx, "нет, не так") if !handled { t.Fatal("«нет, не так» was not read as a correction") } if reply == "" { t.Error("a correction he cannot hear reads as one that was dropped") } labels, err := st.RoutingLabels(ctx, 5) if err != nil { t.Fatal(err) } if len(labels) != 1 || labels[0].ShouldBe != "" || labels[0].Was != "query" { t.Fatalf("labels %+v: want one untargeted negative naming what she chose", labels) } // Outside the window it is a fresh sentence, not a verdict. seed("поужинал ещё раз") *now = now.Add(repairWindow + time.Minute) if _, handled := h.resolveUntargetedRepair(ctx, "не так"); handled { t.Error("a correction outside the window was handled") } } // Whole-utterance, never a substring. This is the difference between the // negatives and the markers, and getting it wrong would claim any sentence with // "не так" in it. func TestRepairNegativeIsTheWholeUtterance(t *testing.T) { for _, s := range []string{ "не так поняла", "нет, не так", "ты ошиблась", "неправильно", "wrong", "no, that was wrong", } { if !isRepairNegative(s) { t.Errorf("%q is not read as a correction", s) } } for _, s := range []string{ "это не важно", "напомни не так поздно", "а не завтра", "не так, а вот так — это заметка", "", "нет", } { if isRepairNegative(s) { t.Errorf("%q was read as a correction", s) } } }