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.
191 lines
6.6 KiB
Go
191 lines
6.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/memory"
|
|
"github.com/kami/maven/internal/phraser"
|
|
"github.com/kami/maven/internal/router"
|
|
"github.com/kami/maven/internal/store"
|
|
"github.com/kami/maven/internal/tool"
|
|
"github.com/kami/maven/internal/voice"
|
|
)
|
|
|
|
func TestReactiveNotesReminders(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := newTestStore(t)
|
|
api := ipc.NewStoreAPI(st)
|
|
now := time.Now()
|
|
|
|
emb := router.NewHashEmbedder(1024)
|
|
matcher := tool.NewMatcher(api)
|
|
rtr := buildRouter(emb, matcher, 0.55, nil, nil)
|
|
|
|
h := &reactiveHandler{
|
|
api: api,
|
|
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
|
router: rtr,
|
|
replier: voice.NewStubReplier(),
|
|
now: func() time.Time { return now },
|
|
dataStore: st,
|
|
}
|
|
|
|
t.Run("reminder capture", func(t *testing.T) {
|
|
fireAt := now.Add(24 * time.Hour).Truncate(time.Millisecond)
|
|
dec := router.Decision{
|
|
Intent: router.IntentReminder,
|
|
Utterance: "напомни завтра позвонить маме",
|
|
Slots: router.Slots{
|
|
Text: "завтра позвонить маме",
|
|
Time: fireAt,
|
|
HasTime: true,
|
|
},
|
|
}
|
|
// The confirmation is phrased from the row now (Vikunja #507), so it
|
|
// names the stored hour rather than leaving the replier to read one
|
|
// out of the sentence.
|
|
reply := h.applyAction(ctx, dec)
|
|
if want := "хорошо, напомню завтра в " + fireAt.Format("15:04") + "."; reply != want {
|
|
t.Errorf("reply = %q, want %q", reply, want)
|
|
}
|
|
reminders, err := st.ListReminders(ctx, 10)
|
|
if err != nil {
|
|
t.Fatalf("ListReminders: %v", err)
|
|
}
|
|
if len(reminders) == 0 {
|
|
t.Fatal("expected at least one reminder, got none")
|
|
}
|
|
last := reminders[0]
|
|
if last.FireTs.UnixMilli() != fireAt.UnixMilli() {
|
|
t.Errorf("reminder FireTs = %v, want %v", last.FireTs, fireAt)
|
|
}
|
|
if last.Status != "pending" {
|
|
t.Errorf("reminder Status = %q, want pending", last.Status)
|
|
}
|
|
})
|
|
|
|
t.Run("note capture", func(t *testing.T) {
|
|
dec := router.Decision{
|
|
Intent: router.IntentNote,
|
|
Utterance: "запомни что кофе закончился",
|
|
}
|
|
reply := h.applyAction(ctx, dec)
|
|
if reply != "" {
|
|
t.Errorf("expected empty reply from applyAction, got %q", reply)
|
|
}
|
|
notes, err := st.RecentNotes(ctx, 10)
|
|
if err != nil {
|
|
t.Fatalf("RecentNotes: %v", err)
|
|
}
|
|
if len(notes) == 0 {
|
|
t.Fatal("expected at least one note, got none")
|
|
}
|
|
last := notes[0]
|
|
if last.Text != "кофе закончился" {
|
|
t.Errorf("note text = %q, want %q", last.Text, "кофе закончился")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestRunTurnExplicitNoteStoresOnlyTheBody pins the live failure end to end:
|
|
// a routed text turn reaches actionNote, stores only the dictated body in both
|
|
// durable and vector memory, and cannot ask the resident model to choose the
|
|
// acknowledgement's grammatical gender (V-721).
|
|
func TestRunTurnExplicitNoteStoresOnlyTheBody(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := newTestStore(t)
|
|
api := ipc.NewStoreAPI(st)
|
|
now := time.Date(2026, 8, 15, 8, 0, 0, 0, time.FixedZone("+04", 4*60*60))
|
|
emb := router.NewHashEmbedder(1024)
|
|
mem := memory.NewInMemoryStore()
|
|
rtr := router.New(router.Config{
|
|
Grammars: []router.Grammar{{
|
|
Name: "explicit-note-test",
|
|
Decide: func(string) (router.Decision, bool) {
|
|
return router.Decision{
|
|
Stage: 0, Intent: router.IntentNote, Confidence: 1,
|
|
// Deliberately hostile model slot: neither persistence nor
|
|
// acknowledgement may use it.
|
|
Slots: router.Slots{Text: "ты поедешь на дачу"},
|
|
}, true
|
|
},
|
|
}},
|
|
Threshold: 0.55,
|
|
})
|
|
model := &countingCompleter{out: `{"response":"Хорошо, сохранил.","mood":"neutral"}`}
|
|
h := &reactiveHandler{
|
|
api: api, router: rtr,
|
|
recall: recallWiring{embedder: emb, memStore: mem},
|
|
replier: newLLMReplier(model, nil),
|
|
now: func() time.Time { return now },
|
|
dataStore: st,
|
|
}
|
|
|
|
const utterance = "запомни: запасной ключ лежит в синей коробке"
|
|
if reply := h.runTurn(ctx, router.NormalizedInput{Text: utterance, Source: sourceText}); reply != "сохранила заметку." {
|
|
t.Fatalf("reply = %q, want the fixed feminine acknowledgement", reply)
|
|
}
|
|
if model.calls != 0 {
|
|
t.Fatalf("resident model was called %d time(s) for a note acknowledgement", model.calls)
|
|
}
|
|
notes, err := st.RecentNotes(ctx, 10)
|
|
if err != nil {
|
|
t.Fatalf("RecentNotes: %v", err)
|
|
}
|
|
const body = "запасной ключ лежит в синей коробке"
|
|
if len(notes) != 1 || notes[0].Text != body || notes[0].Source != "tap:voice" || !notes[0].Ts.Equal(now) {
|
|
t.Fatalf("stored notes = %+v, want one exact body at the turn time", notes)
|
|
}
|
|
records, err := mem.ByPrefix(ctx, "note:")
|
|
if err != nil {
|
|
t.Fatalf("vector catalog: %v", err)
|
|
}
|
|
if len(records) != 1 || records[0].Meta["text"] != body {
|
|
t.Fatalf("vector records = %+v, want the same extracted body", records)
|
|
}
|
|
if records[0].Meta["text"] == utterance || records[0].Meta["text"] == "ты поедешь на дачу" {
|
|
t.Fatalf("vector metadata used a command or model rewrite: %+v", records[0].Meta)
|
|
}
|
|
if !phraser.IsAck(phraser.AckNote, nil, "сохранила заметку.") {
|
|
t.Fatal("fixed acknowledgement is not registered as the note acknowledgement")
|
|
}
|
|
}
|
|
|
|
// TestSpokenTaskCaptureFilesATask — the whole path, from the utterance to the
|
|
// task table. It went dead when the router started claiming the marker as an
|
|
// act: capture rides the note intent, so nothing below actionNote was ever
|
|
// reached and every capture answered "Что сделать?" (Vikunja #467).
|
|
func TestSpokenTaskCaptureFilesATask(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := newTestStore(t)
|
|
api := ipc.NewStoreAPI(st)
|
|
now := time.Now()
|
|
emb := router.NewHashEmbedder(1024)
|
|
matcher := tool.NewMatcher(api)
|
|
h := &reactiveHandler{
|
|
api: api,
|
|
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
|
router: buildRouter(emb, matcher, 0.55, nil, nil),
|
|
replier: voice.NewStubReplier(),
|
|
now: func() time.Time { return now },
|
|
dataStore: st,
|
|
}
|
|
|
|
reply := h.handleText(ctx, "web", "добавь в задачи купить молоко")
|
|
if !strings.Contains(reply, "купить молоко") {
|
|
t.Fatalf("capture did not claim the turn: %q", reply)
|
|
}
|
|
open, err := st.ListTasks(ctx, store.TaskOpen)
|
|
if err != nil || len(open) != 1 {
|
|
t.Fatalf("task was not filed: tasks=%v err=%v", open, err)
|
|
}
|
|
// The words he said, not the model's rewrite of them.
|
|
if open[0].Text != "купить молоко" {
|
|
t.Fatalf("task text was rewritten: %q", open[0].Text)
|
|
}
|
|
}
|