Files
Maven/internal/memory/store.go
T
kami 7c7bd8ceeb Ship voice enrolment, and report recognition as blocked (#255)
Maven can now be told who someone is. She cannot yet tell who is speaking,
and this commit is careful to say so rather than pretend otherwise.

What works: profiles are enrolled from several deliberately recorded samples,
listed, and deleted. They live in the existing memory_vectors table under a
"speaker:" id prefix, so there is no migration; what that needed was a wider
interface than memory.Store, hence memory.Catalog with ByPrefix and Delete.
Delete is the load-bearing half — a voiceprint someone asked to be rid of has
to actually go, and a search-only store cannot do that. InMemoryStore.Insert
became an upsert by id to match what the persistent store already did.

What does not work, and why it is not faked: there is no speaker-embedding
model on this box. Sixteen ggufs in /mnt/hdd1/llms, all text; no ECAPA, no
x-vector, no titanet, no wespeaker, no .onnx anywhere under /mnt/hdd1. So
newSpeakerEmbedder returns nil, internal/speaker falls back to
speaker.Disabled, Identify answers ErrDisabled, and the daemon logs which
half is off at startup. The plan's "simple MFCC + GMM" floor is refused in
the package comment: MFCC cosine distance detects channel and loudness as
much as voice, and a biometric that is confidently wrong writes false claims
about named people into his memory. A bad floor is worse than none here.

Refused as well, and the reason is in enroll.go's doc comment: the plan asked
for unknown speakers to be enrolled on first interaction with a TTS "кто
это?". There is no request shape in the protocol that could express that.
Taking a biometric of whoever walks past the microphone does it to guests who
are not party to the exchange, and a synthesised question into a room is not
consent from whoever answers.

Authority: enrolment is AuthStepUp, because it is a deliberate sit-down act
that writes a biometric of a named person and never something done by voice
mid-conversation. Deletion is one rung lower at AuthWrite, deliberately
inverting the usual pattern — getting rid of a biometric must never be the
harder half. Listing is AuthRead and never returns the vectors themselves.

Off unless configured: no speaker block means the three methods answer
ErrUnknownMethod, so a default box has no wire path that takes a voiceprint.

make build and make test pass.

Vikunja #255

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 05:23:03 +04:00

164 lines
4.2 KiB
Go

package memory
import (
"context"
"sort"
"strings"
"sync"
)
// Result is a single search hit.
type Result struct {
ID string
Score float64
Meta map[string]string
}
// Store is a vector memory interface.
type Store interface {
Insert(ctx context.Context, id string, vec []float32, meta map[string]string) error
Search(ctx context.Context, vec []float32, topK int) ([]Result, error)
}
// Record is a stored vector read back whole — id, vector and metadata — as
// opposed to Result, which is a search hit and carries a score instead of the
// vector.
type Record struct {
ID string
Vec []float32
Meta map[string]string
}
// Catalog is a Store that can also be enumerated by id prefix and deleted from.
//
// Search is not enough for every user of the vector table. Speaker profiles
// (internal/speaker) need to list exactly their own rows without scoring
// anything, because listing enrolled voices is not a similarity question, and
// they need Delete because a voiceprint is data about a person and "forget this
// voice" has to actually remove it. Note and fact recall use plain Store and are
// unaffected.
type Catalog interface {
Store
// ByPrefix returns every row whose id starts with prefix, in no particular
// order. An empty prefix returns everything.
ByPrefix(ctx context.Context, prefix string) ([]Record, error)
// Delete removes one row by id. Deleting a row that is not there is not an
// error: the caller asked for it to be gone and it is gone.
Delete(ctx context.Context, id string) error
}
// item is a single stored vector with metadata.
type item struct {
id string
vec []float32
meta map[string]string
}
// InMemoryStore implements Store with cosine similarity search.
type InMemoryStore struct {
mu sync.RWMutex
items []item
}
// compile-time check: InMemoryStore satisfies Catalog.
var _ Catalog = (*InMemoryStore)(nil)
func NewInMemoryStore() *InMemoryStore {
return &InMemoryStore{}
}
// Insert upserts by id, matching the persistent store.MemoryStore: a repeated
// id replaces the prior row rather than accumulating a second copy. Re-indexing
// a note is an update, and re-enrolling a voice must replace the old voiceprint
// rather than leave it searchable.
func (s *InMemoryStore) Insert(_ context.Context, id string, vec []float32, meta map[string]string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.items {
if s.items[i].id == id {
s.items[i] = item{id: id, vec: vec, meta: meta}
return nil
}
}
s.items = append(s.items, item{id: id, vec: vec, meta: meta})
return nil
}
// ByPrefix implements Catalog.
func (s *InMemoryStore) ByPrefix(_ context.Context, prefix string) ([]Record, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var out []Record
for _, it := range s.items {
if !strings.HasPrefix(it.id, prefix) {
continue
}
out = append(out, Record{ID: it.id, Vec: append([]float32(nil), it.vec...), Meta: it.meta})
}
return out, nil
}
// Delete implements Catalog.
func (s *InMemoryStore) Delete(_ context.Context, id string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.items {
if s.items[i].id == id {
s.items = append(s.items[:i], s.items[i+1:]...)
return nil
}
}
return nil
}
func (s *InMemoryStore) Search(_ context.Context, vec []float32, topK int) ([]Result, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if topK <= 0 {
topK = 10
}
type scored struct {
id string
score float64
meta map[string]string
}
scores := make([]scored, 0, len(s.items))
for _, it := range s.items {
score := cosine(vec, it.vec)
scores = append(scores, scored{id: it.id, score: score, meta: it.meta})
}
sort.Slice(scores, func(i, j int) bool {
return scores[i].score > scores[j].score // descending
})
if topK > len(scores) {
topK = len(scores)
}
out := make([]Result, topK)
for i := 0; i < topK; i++ {
out[i] = Result{
ID: scores[i].id,
Score: scores[i].score,
Meta: scores[i].meta,
}
}
return out, nil
}
// cosine similarity (dot product, assumes L2-normalized vectors).
func cosine(a, b []float32) float64 {
if len(a) != len(b) || len(a) == 0 {
return 0
}
var dot float64
for i := range a {
dot += float64(a[i]) * float64(b[i])
}
return dot
}