diff --git a/cmd/mavend/repair_test.go b/cmd/mavend/repair_test.go new file mode 100644 index 0000000..4bf8958 --- /dev/null +++ b/cmd/mavend/repair_test.go @@ -0,0 +1,119 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/router" +) + +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.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.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") + } +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index e7f4764..a13dc6f 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -161,6 +161,7 @@ type reactiveHandler struct { // the y/n answer. ponytail: single slot, single-user box — a second act // while one waits overwrites it (last-asked wins); expires after confirmTTL. mu sync.Mutex + lastRouted *routedTurn // the previous acted turn, for a spoken correction (repair.go) pending *pendingAct pendingRoutine *pendingRoutineConfirm // routine proposal awaiting y/n pendingHexis *pendingHexisExec // mutating Hexis capability awaiting y/n @@ -299,6 +300,14 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour return withNotice(expiredNotice, reply) } + // 4d. spoken correction — "нет, это была заметка" points at the previous + // turn and names what it should have been (repair.go). Before routing, + // like the confirm and clarify turns: routing the correction as a fresh + // utterance files the correction itself instead of fixing anything. + if reply, handled := h.resolveRepair(ctx, text); handled { + return withNotice(expiredNotice, reply) + } + // 5. route. An elliptical follow-up — "а завтра?" — is answered from the // previous turn instead (continuation.go): the intent is the part it is // missing, so no amount of routing recovers it, and the model's guess @@ -359,6 +368,13 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour } } + // Remember what this turn was routed as, so the next utterance can correct + // it. Only turns she acts on: a clarify asked instead of acting, so there + // is nothing yet to be wrong about. + if !dec.Clarify { + h.recordTurn(text, dec.Intent) + } + // 8. action — execute the decision's intent. errors here surface as // short reply text (the user wants to know the action didn't land); // the round-trip stays alive.