diff --git a/cmd/mavend/actions_fact.go b/cmd/mavend/actions_fact.go index 9a8fc93..5645282 100644 --- a/cmd/mavend/actions_fact.go +++ b/cmd/mavend/actions_fact.go @@ -7,6 +7,7 @@ import ( "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/router" + "github.com/kami/maven/internal/store" ) // actionFact handles router.IntentFact: persist a tapped self-fact, index @@ -63,17 +64,25 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s log.Printf("voice: write fact: %v", err) return "не получилось сохранить факт." } - // Index the fact utterance in long-term memory (best-effort, must not - // fail the fact write). Facts aren't in the notes table, so this is the - // only recall path for them — "когда я пил воду?" reads back from here. + // Index the fact in long-term memory (best-effort, must not fail the fact + // write). Facts aren't in the notes table, so this is the only recall path + // for them — "когда я пил воду?" reads back from here. + // + // The indexed text is the fact, not the utterance (#493). queryMemory + // returns a fact's stored text verbatim, so what goes in here is what he + // hears; storing the utterance meant recall answered with his own sentence + // rather than the value. The utterance stays alongside as provenance — + // readable on /trace, never the answer and never embedded. if h.memStore != nil { - if vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance); err != nil { + text := store.FactRecallText(dec.Slots.Key, dec.Slots.Value) + if vec, err := router.EmbedPassage(ctx, h.embedder, text); err != nil { log.Printf("voice: embed fact for memory: %v", err) } else if err := h.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{ - "source": "voice", - "type": "fact", - "text": dec.Utterance, - "ts": strconv.FormatInt(now.Unix(), 10), + "source": "voice", + "type": "fact", + "text": text, + "utterance": dec.Utterance, + "ts": strconv.FormatInt(now.Unix(), 10), }); err != nil { log.Printf("voice: memory insert fact: %v", err) } diff --git a/cmd/mavend/factgate_test.go b/cmd/mavend/factgate_test.go index 89cca2e..5dc205f 100644 --- a/cmd/mavend/factgate_test.go +++ b/cmd/mavend/factgate_test.go @@ -78,6 +78,22 @@ func TestActionFact_ExplicitCaptureStillWrites(t *testing.T) { 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.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) { diff --git a/internal/store/facts.go b/internal/store/facts.go index f8bbef7..6e0a2c6 100644 --- a/internal/store/facts.go +++ b/internal/store/facts.go @@ -47,6 +47,39 @@ func (s *Store) WriteFact(ctx context.Context, ts time.Time, kind FactKind, key, return id, nil } +// FactRecallText is the text a fact is indexed under and read back as (#493). +// +// It used to be the utterance that wrote the fact, so recall of ANY +// voice-tapped fact answered with the sentence he said instead of the value +// stored: `go_version = 1.20` was indexed as "какая последняя версия языка +// Go?", and that question is what came back. The poisoned rows made the defect +// visible; the shape was wrong for legitimate facts too. +// +// The key is spoken with its underscores dropped, because a key is written for +// the store and this string is read out loud. +func FactRecallText(key, value string) string { + spoken := strings.TrimSpace(strings.ReplaceAll(key, "_", " ")) + v := strings.TrimSpace(DecodeFactValue(value)) + switch { + case v == "": + return spoken + case spoken == "": + return v + } + return spoken + " — " + v +} + +// DecodeFactValue unwraps a stored value for reading. The column holds raw json +// when the writer serialized one (SetValue, CorrectValue) and a plain string +// when it did not (a voice tap), so a reader that wants the text handles both. +func DecodeFactValue(value string) string { + var s string + if err := json.Unmarshal([]byte(value), &s); err == nil { + return s + } + return value +} + // LatestFact returns the latest non-voided fact for key, or ErrNoFact. // "Non-voided" = no later row has voids_id pointing at it. We resolve this by // taking the newest row whose id is not referenced by any voids_id. @@ -291,6 +324,20 @@ func (s *Store) CorrectValue(ctx context.Context, key, source string, value any, if err != nil { return 0, fmt.Errorf("last insert id: %w", err) } + // The same repair a void needs, for the same reason (#493). A correction + // supersedes the value, and the vector still holds the old one, so recall + // kept answering with the value he had just corrected. Dropping it costs + // the key its recall vector until the fact is tapped again: this layer has + // no embedder, and a missing vector loses a question while a stale one + // answers it wrongly. + // + // Best-effort: the corrected row is committed, and a correction that lands + // beats one that fails on cleanup. + if n, derr := s.VectorMemory().DeletePrefix(ctx, "fact:"+key+":"); derr != nil { + log.Printf("store: correct %q: memory vectors survive: %v", key, derr) + } else if n > 0 { + log.Printf("store: correct %q: dropped %d superseded memory vector(s)", key, n) + } return newID, nil }