d32eae8aac
The gesture was web-only, so the sample was skewing to the turns he happens to type. Voice is where the hard cases are. Half of it already existed: the repair rung has read "нет, это была заметка" since V-455. It taught the classifier and wrote no durable label, so the two paths disagreed about what a correction is. It now writes both. Two sinks and not one on purpose: the classifier seed makes the next turn better today, and the label is what a fitted head trains on after the transcript expires. The trace id is stamped onto the remembered turn after the fact, because the trace is written when the turn ends and recordTurn runs in the middle of it. New: the untargeted half. "нет, не так" writes the negative and redoes nothing, because there is no target to redo it as. Voice needs this more than the web does — naming an intent aloud means saying "заметка" or "факт", which is her vocabulary and not his. repair_negatives is a new closed lexicon set matched against the WHOLE utterance, never as a substring. That is what keeps it apart from repair_markers, where "это не" is a fragment that needs an intent word after it. A member that could appear inside an ordinary sentence does not belong in the set.
245 lines
9.0 KiB
Go
245 lines
9.0 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|