package main import ( "context" "testing" "time" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/memory" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/tool" "github.com/kami/maven/internal/voice" ) func newFactGateHandler(t *testing.T, now time.Time) (*reactiveHandler, ipc.CoreAPI) { t.Helper() st := newTestStore(t) api := ipc.NewStoreAPI(st) emb := router.NewHashEmbedder(1024) h := &reactiveHandler{ api: api, recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()}, router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil, nil), replier: voice.NewStubReplier(), now: func() time.Time { return now }, dataStore: st, } return h, api } // The write half of #470: a question routed to IntentFact must not become a // fact about him, and must not leave a vector behind for recall to serve. func TestActionFact_QuestionIsNotWritten(t *testing.T) { ctx := context.Background() h, api := newFactGateHandler(t, time.Now()) reply := h.actionFact(ctx, router.Decision{ Intent: router.IntentFact, Utterance: "какая последняя версия языка Go?", Slots: router.Slots{Key: "go_version", HasKey: true, Value: `"1.20"`}, }) if _, err := api.LatestFact(ctx, "go_version"); err == nil { t.Fatal("a question was stored as a fact about him") } hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "какая последняя версия языка Go?"), 3) if err != nil { t.Fatalf("memory search: %v", err) } if len(hits) != 0 { t.Fatalf("the question was indexed for recall: %+v", hits) } // It went down the query chain instead. Nothing is configured to answer a // world question in this harness, so "не знаю." is the honest outcome — // what matters is that the turn was answered, not stored. if reply == "" { t.Fatal("the turn was neither stored nor answered") } } // The capture that must survive the gate: an explicit instruction to record, // even though it contains an interrogative. func TestActionFact_ExplicitCaptureStillWrites(t *testing.T) { ctx := context.Background() h, api := newFactGateHandler(t, time.Now()) h.actionFact(ctx, router.Decision{ Intent: router.IntentFact, Utterance: "запиши что я пил воду", Slots: router.Slots{Key: "water", HasKey: true, Value: `"вода"`}, }) f, err := api.LatestFact(ctx, "water") if err != nil { t.Fatalf("an explicit capture was refused: %v", err) } if f.Confidence != 1.0 { t.Errorf("confidence = %v, want 1.0 for a value he said", f.Confidence) } // #493: what recall reads back is the fact, not the sentence he said. // queryMemory returns a fact's text verbatim, so the utterance sitting here // meant "запиши что я пил воду" was the answer to "когда я пил воду?". hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 3) if err != nil { t.Fatalf("memory search: %v", err) } if len(hits) != 1 { t.Fatalf("the fact was not indexed once: %+v", hits) } if got := hits[0].Meta["text"]; got != "water — вода" { t.Errorf("indexed text = %q, want the fact", got) } if got := hits[0].Meta["utterance"]; got != "запиши что я пил воду" { t.Errorf("utterance provenance = %q, want it kept alongside", got) } } func TestFactConfidence(t *testing.T) { cases := []struct { utterance, value string want float64 }{ {"запиши что я пил воду", `"вода"`, 1.0}, {"я выпил кофе", `"кофе"`, 1.0}, {"поужинал", "", 1.0}, {"отметь что я полил кактус", `"полил кактус"`, 1.0}, {"какая последняя версия языка Go", `"1.20"`, ungroundedConfidence}, {"кто премьер Японии", `"Тонио Озаки"`, ungroundedConfidence}, } for _, c := range cases { if got := factConfidence(c.utterance, c.value); got != c.want { t.Errorf("factConfidence(%q, %q) = %v, want %v", c.utterance, c.value, got, c.want) } } } func mustEmbedPassage(t *testing.T, h *reactiveHandler, text string) []float32 { t.Helper() vec, err := router.EmbedQuery(context.Background(), h.recall.embedder, text) if err != nil { t.Fatalf("embed %q: %v", text, err) } return vec } // The write half of #481: a complaint about a thing is a state of the // afternoon, not a fact about him. Stored as a `self` row at confidence 1.00 // it comes back on recall as if the network were still down. func TestActionFact_ComplaintIsNotWritten(t *testing.T) { ctx := context.Background() h, api := newFactGateHandler(t, time.Now()) reply := h.actionFact(ctx, router.Decision{ Intent: router.IntentFact, Utterance: "сеть какая-то медленная", Slots: router.Slots{Key: "network_speed", HasKey: true, Value: "медленная"}, }) if _, err := api.LatestFact(ctx, "network_speed"); err == nil { t.Fatal("a passing complaint was stored as a fact about him") } hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "сеть какая-то медленная"), 3) if err != nil { t.Fatalf("memory search: %v", err) } if len(hits) != 0 { t.Fatalf("the complaint was indexed for recall: %+v", hits) } if reply == "" { t.Fatal("the turn was neither stored nor answered") } } // And the complaint he asked her to keep: the capture verb wins, as it does // over the question gate. func TestActionFact_AskedToRememberAComplaintStillWrites(t *testing.T) { ctx := context.Background() h, api := newFactGateHandler(t, time.Now()) h.actionFact(ctx, router.Decision{ Intent: router.IntentFact, Utterance: "запомни что интернет не работает", Slots: router.Slots{Key: "internet", HasKey: true, Value: "не работает"}, }) if _, err := api.LatestFact(ctx, "internet"); err != nil { t.Fatalf("an explicit capture was refused: %v", err) } } // A second tap of the same key supersedes the first, so recall must hold one // vector and it must be the new value (Vikunja #493). Before this the id // carried a timestamp, both rows stayed, and the superseded value went on // competing for the turn. func TestActionFact_ARetapSupersedesTheOldVector(t *testing.T) { ctx := context.Background() h, _ := newFactGateHandler(t, time.Now()) h.actionFact(ctx, router.Decision{ Intent: router.IntentFact, Utterance: "запиши что я пил воду", Slots: router.Slots{Key: "water", HasKey: true, Value: `"250мл"`}, }) // A later tap of the same key. The clock moves, so the old id and the new // one differ — which is exactly what used to leave two rows behind. h.now = func() time.Time { return time.Now().Add(time.Hour) } h.actionFact(ctx, router.Decision{ Intent: router.IntentFact, Utterance: "запиши что я пил воду", Slots: router.Slots{Key: "water", HasKey: true, Value: `"500мл"`}, }) hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 5) if err != nil { t.Fatalf("memory search: %v", err) } if len(hits) != 1 { t.Fatalf("want one vector for the key, got %d: %+v", len(hits), hits) } if got := hits[0].Meta["text"]; got != "water — 500мл" { t.Errorf("indexed text = %q, want the current value", got) } } // Another key is not this key. A prefix delete that widened would take the // whole index with it. func TestActionFact_ARetapLeavesOtherKeysAlone(t *testing.T) { ctx := context.Background() h, _ := newFactGateHandler(t, time.Now()) h.actionFact(ctx, router.Decision{ Intent: router.IntentFact, Utterance: "запиши что я обедал", Slots: router.Slots{Key: "meal", HasKey: true, Value: `"суп"`}, }) h.actionFact(ctx, router.Decision{ Intent: router.IntentFact, Utterance: "запиши что я пил воду", Slots: router.Slots{Key: "water", HasKey: true, Value: `"250мл"`}, }) hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "обед"), 5) if err != nil { t.Fatalf("memory search: %v", err) } var found bool for _, hit := range hits { if hit.Meta["text"] == "meal — суп" { found = true } } if !found { t.Fatalf("writing water dropped the meal vector: %+v", hits) } }