87a3b163e7
First behavior-preserving slice of the Maven redesign. Establishes explicit ingress/routing boundaries and enough observability to refactor later without changing current routing, action, clarification, or execution semantics. Types introduced: - NormalizedInput (internal/router/source.go): Text + InputSource, the typed ingress boundary replacing raw string at the turn entry. - InputSource (internal/router/source.go): channel provenance enum (tap:voice, tap:text). Reuses the existing turnSource distinction. - RouteProducer (internal/router/intent.go): which cascade stage produced the decision (grammar, heads, llm, classifier). Changes: - Decision carries a Producer RouteProducer field, set at each cascade stage (grammar, heads, LLM, classifier). - turnRoute carries NormalizedInput instead of bare text string. - runTurn takes NormalizedInput instead of (text, src). - decision.Record carries InputSource and RouteProducer for observability; RoutingTrace persists route_producer (migration #27). - turnSource is now a type alias for router.InputSource. Behavior preserved: - Stage-0 grammars unchanged: same order, same matching, same confidence. - Cascade fallthrough order unchanged (grammar → heads → llm → classifier). - Clarification behavior unchanged. - Action dispatch unchanged. - No new linguistic normalization.
426 lines
19 KiB
Go
426 lines
19 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/decision"
|
|
"github.com/kami/maven/internal/dialogue"
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/router"
|
|
"github.com/kami/maven/internal/store"
|
|
"github.com/kami/maven/internal/tts"
|
|
)
|
|
|
|
func TestParseReminderCancelRequest(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
text string
|
|
ok bool
|
|
}{
|
|
{"отмени напоминание про врача", true},
|
|
{"убери моё напоминание о визите", true},
|
|
{"удали будильник на девять", true},
|
|
{"пожалуйста, Maven, cancel the reminder about doctor", true},
|
|
{"отмени напоминание, которое стоит на завтра", true},
|
|
{"напоминание про врача", false},
|
|
{"отмени задачу про врача", false},
|
|
{"я отменил напоминание про врача", false},
|
|
{"как отменить напоминание про врача?", false},
|
|
{"можно отменить напоминание про врача?", false},
|
|
{"он сказал: отмени напоминание про врача", false},
|
|
{"how to cancel the reminder about doctor?", false},
|
|
{"can you cancel the reminder about doctor?", false},
|
|
{"убери со стола", false},
|
|
{"отмена", false},
|
|
} {
|
|
_, ok := parseReminderCancelRequest(tc.text)
|
|
if ok != tc.ok {
|
|
t.Errorf("parseReminderCancelRequest(%q) ok = %v, want %v", tc.text, ok, tc.ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReminderCancellationTermsPreserveIdentity(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
text string
|
|
hasTime bool
|
|
want []string
|
|
}{
|
|
{"отмени напоминание про врача", false, []string{"врача"}},
|
|
{"отмени напоминание не звонить врачу", false, []string{"не", "звонить", "врачу"}},
|
|
{"отмени напоминание принять две таблетки", false, []string{"принять", "две", "таблетки"}},
|
|
{"отмени напоминание принять две таблетки на девять", true, []string{"принять", "две", "таблетки"}},
|
|
{"cancel the reminder to take 2 pills at 21:30", true, []string{"take", "2", "pills"}},
|
|
} {
|
|
got := reminderCancellationTerms(tc.text, tc.hasTime)
|
|
if strings.Join(got, "|") != strings.Join(tc.want, "|") {
|
|
t.Errorf("reminderCancellationTerms(%q) = %v, want %v", tc.text, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func seedVoiceReminder(t *testing.T, st *store.Store, fire time.Time, text string) int64 {
|
|
t.Helper()
|
|
id, err := st.CreateReminder(context.Background(), fire, `{"text":"`+text+`"}`, "")
|
|
if err != nil {
|
|
t.Fatalf("create reminder: %v", err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func reminderStatuses(t *testing.T, st *store.Store) map[int64]string {
|
|
t.Helper()
|
|
rows, err := st.ListReminders(context.Background(), 100)
|
|
if err != nil {
|
|
t.Fatalf("list reminders: %v", err)
|
|
}
|
|
out := make(map[int64]string, len(rows))
|
|
for _, row := range rows {
|
|
out[row.ID] = row.Status
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestReminderCancellationResolvesSubjectByMorphology(t *testing.T) {
|
|
h, st, now := newClarifyHandler(t)
|
|
h.timeParser = router.StubDateTimeParser{}
|
|
doctor := seedVoiceReminder(t, st, now.Add(3*time.Hour), "позвонить врачу")
|
|
bread := seedVoiceReminder(t, st, now.Add(4*time.Hour), "купить хлеб")
|
|
|
|
reply, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание про врача")
|
|
if !handled || !strings.Contains(reply, "отменила") || !strings.Contains(reply, "позвонить врачу") {
|
|
t.Fatalf("reply = %q, handled=%v", reply, handled)
|
|
}
|
|
statuses := reminderStatuses(t, st)
|
|
if statuses[doctor] != store.ReminderCancelled || statuses[bread] != store.ReminderPending {
|
|
t.Fatalf("statuses = %+v, want doctor cancelled and bread pending", statuses)
|
|
}
|
|
}
|
|
|
|
func TestReminderCancellationKeepsNegationAndQuantityDistinct(t *testing.T) {
|
|
t.Run("negation", func(t *testing.T) {
|
|
h, st, now := newClarifyHandler(t)
|
|
positive := seedVoiceReminder(t, st, now.Add(time.Hour), "звонить врачу")
|
|
negative := seedVoiceReminder(t, st, now.Add(2*time.Hour), "не звонить врачу")
|
|
|
|
reply, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание не звонить врачу")
|
|
if !handled || !strings.Contains(reply, "не звонить врачу") {
|
|
t.Fatalf("reply = %q, handled=%v", reply, handled)
|
|
}
|
|
statuses := reminderStatuses(t, st)
|
|
if statuses[positive] != store.ReminderPending || statuses[negative] != store.ReminderCancelled {
|
|
t.Fatalf("negation selected the wrong row: %+v", statuses)
|
|
}
|
|
})
|
|
|
|
t.Run("quantity", func(t *testing.T) {
|
|
h, st, now := newClarifyHandler(t)
|
|
one := seedVoiceReminder(t, st, now.Add(time.Hour), "принять одну таблетку")
|
|
two := seedVoiceReminder(t, st, now.Add(2*time.Hour), "принять две таблетки")
|
|
|
|
reply, handled := h.resolveReminderCancellation(context.Background(), "удали напоминание принять две таблетки")
|
|
if !handled || !strings.Contains(reply, "две таблетки") {
|
|
t.Fatalf("reply = %q, handled=%v", reply, handled)
|
|
}
|
|
statuses := reminderStatuses(t, st)
|
|
if statuses[one] != store.ReminderPending || statuses[two] != store.ReminderCancelled {
|
|
t.Fatalf("quantity selected the wrong row: %+v", statuses)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestReminderCancellationQuestionNeverMutates(t *testing.T) {
|
|
h, st, now := newClarifyHandler(t)
|
|
id := seedVoiceReminder(t, st, now.Add(time.Hour), "позвонить врачу")
|
|
for _, text := range []string{
|
|
"как отменить напоминание про врача?",
|
|
"можно отменить напоминание про врача?",
|
|
"он сказал: отмени напоминание про врача",
|
|
} {
|
|
if reply, handled := h.resolveReminderCancellation(context.Background(), text); handled || reply != "" {
|
|
t.Fatalf("non-command %q was claimed: reply=%q handled=%v", text, reply, handled)
|
|
}
|
|
if got := reminderStatuses(t, st)[id]; got != store.ReminderPending {
|
|
t.Fatalf("non-command %q changed reminder to %q", text, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReminderCancellationUsesClockAndAsksWhenStateIsAmbiguous(t *testing.T) {
|
|
t.Run("one matching half of day is enough", func(t *testing.T) {
|
|
h, st, now := newClarifyHandler(t)
|
|
h.timeParser = router.StubDateTimeParser{}
|
|
evening := seedVoiceReminder(t, st, time.Date(now.Year(), now.Month(), now.Day(), 21, 0, 0, 0, now.Location()), "вечернее лекарство")
|
|
seedVoiceReminder(t, st, now.Add(2*time.Hour), "купить хлеб")
|
|
|
|
reply, handled := h.resolveReminderCancellation(context.Background(), "убери напоминание на девять")
|
|
if !handled || !strings.Contains(reply, "отменила") {
|
|
t.Fatalf("reply = %q, handled=%v", reply, handled)
|
|
}
|
|
if got := reminderStatuses(t, st)[evening]; got != store.ReminderCancelled {
|
|
t.Fatalf("21:00 status = %q, want cancelled", got)
|
|
}
|
|
})
|
|
|
|
t.Run("two matching halves are offered and ordinal is bound", func(t *testing.T) {
|
|
h, st, now := newClarifyHandler(t)
|
|
h.timeParser = router.StubDateTimeParser{}
|
|
evening := seedVoiceReminder(t, st, time.Date(now.Year(), now.Month(), now.Day(), 21, 0, 0, 0, now.Location()), "вечернее лекарство")
|
|
morning := seedVoiceReminder(t, st, time.Date(now.Year(), now.Month(), now.Day()+1, 9, 0, 0, 0, now.Location()), "утреннее лекарство")
|
|
|
|
reply, handled := h.resolveReminderCancellation(context.Background(), "убери напоминание на девять")
|
|
if !handled || !strings.Contains(reply, "порядковым словом") {
|
|
t.Fatalf("ambiguous reply = %q, handled=%v", reply, handled)
|
|
}
|
|
statuses := reminderStatuses(t, st)
|
|
if statuses[evening] != store.ReminderPending || statuses[morning] != store.ReminderPending {
|
|
t.Fatalf("ambiguous command mutated rows: %+v", statuses)
|
|
}
|
|
sess := h.dialogueSessions.Get(dialogueIDOf(context.Background()), h.now())
|
|
if sess == nil || len(sess.Candidates) != 2 || sess.Candidates[1].Ref != morning {
|
|
t.Fatalf("bound candidates = %+v", sess)
|
|
}
|
|
|
|
reply, handled = h.resolveCandidate(context.Background(), "второе", sourceVoice)
|
|
if !handled || !strings.Contains(reply, "утреннее лекарство") {
|
|
t.Fatalf("ordinal reply = %q, handled=%v", reply, handled)
|
|
}
|
|
statuses = reminderStatuses(t, st)
|
|
if statuses[evening] != store.ReminderPending || statuses[morning] != store.ReminderCancelled {
|
|
t.Fatalf("ordinal cancelled the wrong row: %+v", statuses)
|
|
}
|
|
if sess := h.dialogueSessions.Get(dialogueIDOf(context.Background()), h.now()); sess == nil || len(sess.Candidates) != 0 {
|
|
t.Fatalf("spent candidates survived: %+v", sess)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestReminderCancellationChoiceRequiresAWholeAffirmativeOrdinal(t *testing.T) {
|
|
unsafe := []string{
|
|
"почему второе?",
|
|
"не второе",
|
|
"второе не отменяй",
|
|
"первое и второе",
|
|
"напомни мне первого сентября оплатить счёт",
|
|
}
|
|
for _, answer := range unsafe {
|
|
t.Run(answer, func(t *testing.T) {
|
|
h, st, now := newClarifyHandler(t)
|
|
h.timeParser = router.StubDateTimeParser{}
|
|
first := seedVoiceReminder(t, st, now.Add(time.Hour), "первое лекарство")
|
|
second := seedVoiceReminder(t, st, now.Add(2*time.Hour), "второе лекарство")
|
|
if _, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание"); !handled {
|
|
t.Fatal("ambiguous cancellation was not offered")
|
|
}
|
|
if reply, handled := h.resolveCandidate(context.Background(), answer, sourceVoice); handled || reply != "" {
|
|
t.Fatalf("unsafe answer was claimed: reply=%q handled=%v", reply, handled)
|
|
}
|
|
statuses := reminderStatuses(t, st)
|
|
if statuses[first] != store.ReminderPending || statuses[second] != store.ReminderPending {
|
|
t.Fatalf("unsafe answer mutated rows: %+v", statuses)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReminderCancellationChoiceCanBeAbandoned(t *testing.T) {
|
|
for _, answer := range []string{"отмена", "не надо", "no"} {
|
|
t.Run(answer, func(t *testing.T) {
|
|
h, st, now := newClarifyHandler(t)
|
|
first := seedVoiceReminder(t, st, now.Add(time.Hour), "первое")
|
|
second := seedVoiceReminder(t, st, now.Add(2*time.Hour), "второе")
|
|
if _, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание"); !handled {
|
|
t.Fatal("ambiguous cancellation was not offered")
|
|
}
|
|
reply, handled := h.resolveCandidate(context.Background(), answer, sourceVoice)
|
|
if !handled || !strings.Contains(reply, "ничего не отменяю") {
|
|
t.Fatalf("cancel answer = %q handled=%v", reply, handled)
|
|
}
|
|
statuses := reminderStatuses(t, st)
|
|
if statuses[first] != store.ReminderPending || statuses[second] != store.ReminderPending {
|
|
t.Fatalf("abandoning the choice mutated rows: %+v", statuses)
|
|
}
|
|
if sess := h.dialogueSessions.Get(dialogueIDOf(context.Background()), h.now()); sess == nil || len(sess.Candidates) != 0 {
|
|
t.Fatalf("abandoned candidates survived: %+v", sess)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReminderCancellationOfferStartsFreshAndNamesTruncation(t *testing.T) {
|
|
h, st, now := newClarifyHandler(t)
|
|
id := dialogueIDOf(context.Background())
|
|
h.dialogueSessions.Put(id, &dialogue.Session{
|
|
Intent: dialogue.IntentReminder,
|
|
Slots: dialogue.Slots{Text: "stale subject", HasTime: true, Time: now.Add(time.Hour)},
|
|
Timestamp: now.Add(-time.Minute),
|
|
})
|
|
for i := 0; i < 6; i++ {
|
|
seedVoiceReminder(t, st, now.Add(time.Duration(i+1)*time.Hour), fmt.Sprintf("row %d", i+1))
|
|
}
|
|
reply, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание")
|
|
if !handled || !strings.Contains(reply, "первые пять") || !strings.Contains(reply, "уточни текст или время") {
|
|
t.Fatalf("truncated offer = %q handled=%v", reply, handled)
|
|
}
|
|
sess := h.dialogueSessions.Get(id, h.now())
|
|
if sess == nil || sess.Intent != dialogue.IntentSystem || sess.Slots.Text != "" ||
|
|
len(sess.Candidates) != 5 || sess.Utterance != "отмени напоминание" {
|
|
t.Fatalf("offer reused stale dialogue state: %+v", sess)
|
|
}
|
|
}
|
|
|
|
func TestReminderCancellationNeverGuesses(t *testing.T) {
|
|
t.Run("bare command over several rows", func(t *testing.T) {
|
|
h, st, now := newClarifyHandler(t)
|
|
first := seedVoiceReminder(t, st, now.Add(time.Hour), "первое")
|
|
second := seedVoiceReminder(t, st, now.Add(2*time.Hour), "второе")
|
|
reply, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание")
|
|
if !handled || !strings.Contains(reply, "порядковым словом") {
|
|
t.Fatalf("reply = %q, handled=%v", reply, handled)
|
|
}
|
|
statuses := reminderStatuses(t, st)
|
|
if statuses[first] != store.ReminderPending || statuses[second] != store.ReminderPending {
|
|
t.Fatalf("bare ambiguous command mutated rows: %+v", statuses)
|
|
}
|
|
})
|
|
|
|
t.Run("unread time", func(t *testing.T) {
|
|
h, st, now := newClarifyHandler(t)
|
|
h.timeParser = router.StubDateTimeParser{}
|
|
id := seedVoiceReminder(t, st, now.Add(time.Hour), "позвонить врачу")
|
|
reply, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание через вечность")
|
|
if !handled || !strings.Contains(reply, "не смогла разобрать время") {
|
|
t.Fatalf("reply = %q, handled=%v", reply, handled)
|
|
}
|
|
if got := reminderStatuses(t, st)[id]; got != store.ReminderPending {
|
|
t.Fatalf("unread time cancelled reminder: %q", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
type cancelReminderAPI struct {
|
|
ipc.UnimplementedCoreAPI
|
|
|
|
rows []ipc.Reminder
|
|
listErr error
|
|
cancelErr error
|
|
calls []int64
|
|
}
|
|
|
|
func (a *cancelReminderAPI) ListPendingReminders(context.Context, int) ([]ipc.Reminder, error) {
|
|
return a.rows, a.listErr
|
|
}
|
|
|
|
func (a *cancelReminderAPI) CancelReminder(_ context.Context, id int64) error {
|
|
a.calls = append(a.calls, id)
|
|
return a.cancelErr
|
|
}
|
|
|
|
func cancelHandler(api ipc.CoreAPI) *reactiveHandler {
|
|
now := time.Date(2026, 8, 15, 9, 0, 0, 0, time.UTC)
|
|
parser := router.StubDateTimeParser{}
|
|
return &reactiveHandler{
|
|
api: api, now: func() time.Time { return now }, timeParser: parser,
|
|
extractor: router.Extractor{Time: parser},
|
|
dialogueSessions: dialogue.NewSessionStore(2 * time.Minute),
|
|
}
|
|
}
|
|
|
|
func TestReminderCancellationReportsStoreOutcomes(t *testing.T) {
|
|
row := ipc.Reminder{
|
|
ID: 7, FireTs: time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC),
|
|
NextFireTs: time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC),
|
|
Payload: `{"text":"позвонить врачу"}`, Status: store.ReminderPending,
|
|
}
|
|
for _, tc := range []struct {
|
|
name string
|
|
err error
|
|
want string
|
|
}{
|
|
{"already terminal", ipc.ErrReminderState, "уже не ожидает"},
|
|
{"delivery in flight", ipc.ErrReminderInFlight, "уже начала отправлять"},
|
|
{"transport", errors.New("socket closed"), "не получилось отменить"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
api := &cancelReminderAPI{rows: []ipc.Reminder{row}, cancelErr: tc.err}
|
|
reply, handled := cancelHandler(api).resolveReminderCancellation(context.Background(), "отмени напоминание про врача")
|
|
if !handled || !strings.Contains(reply, tc.want) || len(api.calls) != 1 || api.calls[0] != 7 {
|
|
t.Fatalf("reply=%q handled=%v calls=%v", reply, handled, api.calls)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("list failure", func(t *testing.T) {
|
|
api := &cancelReminderAPI{listErr: errors.New("offline")}
|
|
reply, handled := cancelHandler(api).resolveReminderCancellation(context.Background(), "отмени напоминание")
|
|
if !handled || !strings.Contains(reply, "не получилось посмотреть") || len(api.calls) != 0 {
|
|
t.Fatalf("reply=%q handled=%v calls=%v", reply, handled, api.calls)
|
|
}
|
|
})
|
|
|
|
t.Run("nothing pending", func(t *testing.T) {
|
|
api := &cancelReminderAPI{}
|
|
reply, handled := cancelHandler(api).resolveReminderCancellation(context.Background(), "отмени напоминание")
|
|
if !handled || !strings.Contains(reply, "ожидающих напоминаний нет") || len(api.calls) != 0 {
|
|
t.Fatalf("reply=%q handled=%v calls=%v", reply, handled, api.calls)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestReminderCancellationIsAPreRouteTurnAndDoesNotGetSwallowedByClarify(t *testing.T) {
|
|
h, st, now := newClarifyHandler(t)
|
|
h.timeParser = router.StubDateTimeParser{}
|
|
h.decisions = decision.NewRing()
|
|
id := seedVoiceReminder(t, st, now.Add(time.Hour), "позвонить врачу")
|
|
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
|
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
|
Intent: dialogue.IntentReminder, Missing: []dialogue.Slot{dialogue.SlotTime},
|
|
Utterance: "напомни позвонить маме", Asked: h.now(), TTL: clarifyTTL,
|
|
})
|
|
|
|
reply := h.runTurn(ctx, router.NormalizedInput{Text: "отмени напоминание про врача", Source: sourceText})
|
|
if !strings.Contains(reply, clarifyDropped) || !strings.Contains(reply, "отменила напоминание") {
|
|
t.Fatalf("reply = %q, want dropped clarify notice and cancellation", reply)
|
|
}
|
|
if h.clarifyStore.Get(dialogueIDOf(ctx), h.now()) != nil {
|
|
t.Fatal("the superseded clarify question survived the cancellation request")
|
|
}
|
|
if got := reminderStatuses(t, st)[id]; got != store.ReminderCancelled {
|
|
t.Fatalf("status = %q, want cancelled", got)
|
|
}
|
|
recs := h.decisions.Recent(1)
|
|
if len(recs) != 1 {
|
|
t.Fatalf("decision records = %d, want 1", len(recs))
|
|
}
|
|
claim := findClaim(recs[0], "reminder-cancel")
|
|
if claim == nil || claim.Outcome != decision.Won {
|
|
t.Fatalf("reminder-cancel claim = %+v, want pre-route winner", claim)
|
|
}
|
|
}
|
|
|
|
func TestReminderCancellationThroughPushToTalk(t *testing.T) {
|
|
h, st, now := newClarifyHandler(t)
|
|
h.stt = simTranscriber{text: "отмени напоминание про врача"}
|
|
h.tts = tts.NewStub()
|
|
h.timeParser = router.StubDateTimeParser{}
|
|
h.router = buildRouter(router.NewHashEmbedder(64), h.matcher, 0.55, nil, nil)
|
|
doctor := seedVoiceReminder(t, st, now.Add(time.Hour), "позвонить врачу")
|
|
bread := seedVoiceReminder(t, st, now.Add(2*time.Hour), "купить хлеб")
|
|
|
|
resp, err := h.HandlePushToTalk(context.Background(), voicePTT(), 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(resp.ReplyText, "отменила напоминание") || len(resp.ReplyAudio.Bytes) == 0 {
|
|
t.Fatalf("PTT response = text %q audio=%d bytes", resp.ReplyText, len(resp.ReplyAudio.Bytes))
|
|
}
|
|
statuses := reminderStatuses(t, st)
|
|
if statuses[doctor] != store.ReminderCancelled || statuses[bread] != store.ReminderPending {
|
|
t.Fatalf("PTT cancellation changed the wrong rows: %+v", statuses)
|
|
}
|
|
}
|