store, mavend: a fact is indexed as the fact, not as the utterance (V-493)
queryMemory returns a fact's stored text verbatim, so the text the write path indexed is what he hears. It was the utterance, which made recall of any voice-tapped fact answer with the sentence he said: go_version = 1.20 was indexed as "какая последняя версия языка Go?", and that question came back. FactRecallText renders the fact instead, and the utterance stays in meta as provenance. Correcting a value now drops the key's vectors the way voiding one does, since the superseded value was still answering. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// actionFact handles router.IntentFact: persist a tapped self-fact, index
|
// 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)
|
log.Printf("voice: write fact: %v", err)
|
||||||
return "не получилось сохранить факт."
|
return "не получилось сохранить факт."
|
||||||
}
|
}
|
||||||
// Index the fact utterance in long-term memory (best-effort, must not
|
// Index the fact in long-term memory (best-effort, must not fail the fact
|
||||||
// fail the fact write). Facts aren't in the notes table, so this is the
|
// write). Facts aren't in the notes table, so this is the only recall path
|
||||||
// only recall path for them — "когда я пил воду?" reads back from here.
|
// 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 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)
|
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{
|
} else if err := h.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{
|
||||||
"source": "voice",
|
"source": "voice",
|
||||||
"type": "fact",
|
"type": "fact",
|
||||||
"text": dec.Utterance,
|
"text": text,
|
||||||
"ts": strconv.FormatInt(now.Unix(), 10),
|
"utterance": dec.Utterance,
|
||||||
|
"ts": strconv.FormatInt(now.Unix(), 10),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("voice: memory insert fact: %v", err)
|
log.Printf("voice: memory insert fact: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,22 @@ func TestActionFact_ExplicitCaptureStillWrites(t *testing.T) {
|
|||||||
if f.Confidence != 1.0 {
|
if f.Confidence != 1.0 {
|
||||||
t.Errorf("confidence = %v, want 1.0 for a value he said", f.Confidence)
|
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) {
|
func TestFactConfidence(t *testing.T) {
|
||||||
|
|||||||
@@ -47,6 +47,39 @@ func (s *Store) WriteFact(ctx context.Context, ts time.Time, kind FactKind, key,
|
|||||||
return id, nil
|
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.
|
// 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
|
// "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.
|
// 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 {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("last insert id: %w", err)
|
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
|
return newID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user