8015fdbb79
Replace nearest-neighbour personal routing with a frozen class-balanced linear head measured on historical, stratified, cross-validation, holdout, and fresh challenge gates (V-702). Close the four repair handoff holes, preserve nested clarification flows, and route Russian possession statements through structural grammar rather than lexical exceptions (V-573). Owner explicitly requested direct commits to master.
366 lines
14 KiB
Go
366 lines
14 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/kami/maven/internal/dialogue"
|
||
"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, 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()
|
||
|
||
h.recordTurn("купить хлеб", router.IntentFact)
|
||
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); !handled {
|
||
t.Fatal("the first correction was not handled")
|
||
}
|
||
before, err := st.RecentNotes(ctx, 10)
|
||
if err != nil || len(before) != 1 {
|
||
t.Fatalf("first repair notes=%+v err=%v", before, err)
|
||
}
|
||
reply, handled := h.resolveRepair(ctx, "нет, это заметка")
|
||
if !handled || !strings.Contains(reply, "уже") {
|
||
t.Fatalf("the repeated correction was not acknowledged as already applied: handled=%v reply=%q", handled, reply)
|
||
}
|
||
after, err := st.RecentNotes(ctx, 10)
|
||
if err != nil || len(after) != 1 {
|
||
t.Fatalf("the same turn was redone twice: notes=%+v err=%v", after, err)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
reply, handled := h.resolveRepair(context.Background(), "нет, это заметка")
|
||
if !handled || !strings.Contains(reply, "уже") {
|
||
t.Fatalf("a redundant correction must be acknowledged without redoing it: handled=%v reply=%q", handled, reply)
|
||
}
|
||
// Acknowledging the redundant target must not spend the original. If this
|
||
// resolver declines instead, runTurn routes the correction as a fresh turn
|
||
// and recordTurn overwrites the pointer even though takeLastTurn retained it.
|
||
if _, handled := h.resolveRepair(context.Background(), "нет, это факт"); !handled {
|
||
t.Error("a redundant same-intent repair spent the original turn")
|
||
}
|
||
}
|
||
|
||
func TestRepairResumesQuestionParkedAfterTheCorrectedTurn(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 _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder,
|
||
router.Slots{Text: "позвонить маме"}, "напомни позвонить маме")); !asked {
|
||
t.Fatal("expected a parked reminder question")
|
||
}
|
||
|
||
reply := h.runTurn(ctx, "нет, это был вопрос", sourceText)
|
||
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
|
||
if !strings.HasSuffix(reply, resumed) {
|
||
t.Fatalf("the correction hid the still-live question: reply=%q want suffix=%q", reply, resumed)
|
||
}
|
||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||
if q == nil {
|
||
t.Fatal("the correction dropped the parked question")
|
||
}
|
||
if q.Attempts != 1 || q.Suspends != 1 {
|
||
t.Fatalf("the correction spent a retry instead of suspending the question: %+v", q)
|
||
}
|
||
}
|
||
|
||
func TestRepairedClarifyCompletesWithoutDroppingTheOlderQuestion(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()
|
||
|
||
h.recordTurn("купить хлеб", router.IntentFact)
|
||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder,
|
||
router.Slots{Text: "позвонить маме"}, "напомни позвонить маме")); !asked {
|
||
t.Fatal("expected the older reminder question")
|
||
}
|
||
|
||
if reply := h.runTurn(ctx, "нет, это было напоминание", sourceText); !strings.Contains(reply, "Когда") {
|
||
t.Fatalf("the repaired reminder did not ask for its missing time: %q", reply)
|
||
}
|
||
if depth := h.clarifyStore.Depth(voiceDialogueID); depth != 2 {
|
||
t.Fatalf("the repaired question overwrote the older one: depth=%d want=2", depth)
|
||
}
|
||
|
||
reply := h.runTurn(ctx, "сегодня в 15:00", sourceText)
|
||
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
|
||
if !strings.HasSuffix(reply, resumed) {
|
||
t.Fatalf("completing the repaired request did not resume the older one: reply=%q", reply)
|
||
}
|
||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||
if q == nil || !strings.Contains(q.Utterance, "маме") {
|
||
t.Fatalf("the older question was lost after the top one completed: %+v", q)
|
||
}
|
||
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||
if err != nil || len(reminders) != 1 || !strings.Contains(reminders[0].Payload, "хлеб") {
|
||
t.Fatalf("the repaired reminder did not land exactly once: reminders=%+v err=%v", reminders, err)
|
||
}
|
||
}
|
||
|
||
func TestRepairedClarifyGiveUpKeepsTheOlderQuestion(t *testing.T) {
|
||
h, _, _ := newClarifyHandler(t)
|
||
ctx := context.Background()
|
||
older := &dialogue.PendingQuestion{
|
||
Intent: dialogue.IntentReminder, Missing: []dialogue.Slot{dialogue.SlotTime},
|
||
Utterance: "напомни позвонить маме", Asked: h.now(), TTL: clarifyTTL,
|
||
}
|
||
top := &dialogue.PendingQuestion{
|
||
Intent: dialogue.IntentReminder, Missing: []dialogue.Slot{dialogue.SlotTime},
|
||
Utterance: "напомни купить хлеб", Asked: h.now(), TTL: clarifyTTL,
|
||
Attempts: dialogue.DefaultMaxAttempts, MaxAttempts: dialogue.DefaultMaxAttempts,
|
||
}
|
||
h.clarifyStore.Push(voiceDialogueID, older)
|
||
h.clarifyStore.Push(voiceDialogueID, top)
|
||
|
||
if reply := h.reaskOrGiveUp(ctx, top, top.Slots, "не знаю", ""); reply != clarifyGaveUp {
|
||
t.Fatalf("reply=%q, want the explicit give-up line", reply)
|
||
}
|
||
if depth := h.clarifyStore.Depth(voiceDialogueID); depth != 1 {
|
||
t.Fatalf("giving up on the top request erased the older flow: depth=%d", depth)
|
||
}
|
||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||
if q != older {
|
||
t.Fatalf("survivor=%+v, want the older parked question", q)
|
||
}
|
||
}
|
||
|
||
func TestStaleRepairDoesNotSpendTheOriginal(t *testing.T) {
|
||
h, _, now := newClarifyHandler(t)
|
||
h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))})
|
||
h.recordTurn("купить хлеб", router.IntentFact)
|
||
*now = now.Add(repairWindow + time.Minute)
|
||
if _, handled := h.resolveRepair(context.Background(), "нет, это заметка"); handled {
|
||
t.Fatal("a stale correction was handled")
|
||
}
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
if h.lastRouted == nil || h.lastRouted.utterance != "купить хлеб" {
|
||
t.Fatal("a stale declined correction spent the original turn")
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
}
|