recall: a re-tapped fact drops its superseded vector (V-493)
The fact vector id carries a timestamp, so tapping the same key twice added a row instead of replacing one and recall then scored the old value against the current one. CorrectValue and VoidLatestFact already prune the key; an ordinary re-tap is the third way a value is superseded and it did not. actionFact now prunes fact:<key>: before inserting, so exactly one vector survives per key. InMemoryStore gained the matching DeletePrefix, because a test double that quietly kept both rows would pass a test the daemon fails. The prune is best-effort and silent on a store that cannot do it: the fact row is the truth, and a stale vector costs a wrong recall, not a lost fact.
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/memory"
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
@@ -89,7 +90,15 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
|||||||
// hears; storing the utterance meant recall answered with his own sentence
|
// hears; storing the utterance meant recall answered with his own sentence
|
||||||
// rather than the value. The utterance stays alongside as provenance —
|
// rather than the value. The utterance stays alongside as provenance —
|
||||||
// readable on /trace, never the answer and never embedded.
|
// readable on /trace, never the answer and never embedded.
|
||||||
|
//
|
||||||
|
// The vector id carries a timestamp, so a second tap of the same key adds a
|
||||||
|
// row rather than replacing one, and recall then scores the superseded
|
||||||
|
// value against the current one. CorrectValue and VoidLatestFact already
|
||||||
|
// drop the key's vectors; an ordinary re-tap is the third way a value is
|
||||||
|
// superseded and it did not (#493). Dropping first keeps exactly one vector
|
||||||
|
// per key, which is what "recall answers with the current value" means.
|
||||||
if h.recall.memStore != nil {
|
if h.recall.memStore != nil {
|
||||||
|
pruneFactVectors(ctx, h.recall.memStore, dec.Slots.Key)
|
||||||
text := store.FactRecallText(dec.Slots.Key, dec.Slots.Value)
|
text := store.FactRecallText(dec.Slots.Key, dec.Slots.Value)
|
||||||
if vec, err := router.EmbedPassage(ctx, h.recall.embedder, text); err != nil {
|
if vec, err := router.EmbedPassage(ctx, h.recall.embedder, text); err != nil {
|
||||||
log.Printf("voice: embed fact for memory: %v", err)
|
log.Printf("voice: embed fact for memory: %v", err)
|
||||||
@@ -114,3 +123,29 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
|||||||
}
|
}
|
||||||
return "" // replier phrases the success reply
|
return "" // replier phrases the success reply
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vectorPruner — the part of the vector index this file needs and memory.Store
|
||||||
|
// does not carry. store.MemoryStore implements it; the in-memory test double
|
||||||
|
// may not, and a double that cannot prune is not a reason to fail a fact write.
|
||||||
|
type vectorPruner interface {
|
||||||
|
DeletePrefix(ctx context.Context, prefix string) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneFactVectors drops every vector for one fact key, so the insert that
|
||||||
|
// follows is the only one left. Best-effort and silent on a store that cannot
|
||||||
|
// prune: the fact row is the truth, and a stale vector costs a wrong recall,
|
||||||
|
// not a lost fact.
|
||||||
|
func pruneFactVectors(ctx context.Context, ms memory.Store, key string) {
|
||||||
|
p, ok := ms.(vectorPruner)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := p.DeletePrefix(ctx, "fact:"+key+":")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: prune memory vectors for %q: %v", key, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
log.Printf("voice: %q superseded, dropped %d stale memory vector(s)", key, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -167,3 +167,69 @@ func TestActionFact_AskedToRememberAComplaintStillWrites(t *testing.T) {
|
|||||||
t.Fatalf("an explicit capture was refused: %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -132,6 +132,26 @@ func (s *InMemoryStore) Delete(_ context.Context, id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeletePrefix removes every row whose id starts with prefix and returns how
|
||||||
|
// many went. It matches store.MemoryStore's method so a test double and the
|
||||||
|
// real index agree about superseding a fact (Vikunja #493) — a double that
|
||||||
|
// silently kept the old vectors would pass a test the daemon fails.
|
||||||
|
func (s *InMemoryStore) DeletePrefix(_ context.Context, prefix string) (int64, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
kept := s.items[:0]
|
||||||
|
var n int64
|
||||||
|
for _, it := range s.items {
|
||||||
|
if strings.HasPrefix(it.id, prefix) {
|
||||||
|
n++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, it)
|
||||||
|
}
|
||||||
|
s.items = kept
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *InMemoryStore) Search(_ context.Context, vec []float32, topK int) ([]Result, error) {
|
func (s *InMemoryStore) Search(_ context.Context, vec []float32, topK int) ([]Result, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|||||||
Reference in New Issue
Block a user