Files
Maven/cmd/mavend/repair_test.go
claude 8015fdbb79 Harden semantic boundaries and repair dialogue state
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.
2026-08-13 03:00:31 +04:00

366 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}