Changeset 0.18.0 (#79)
This commit is contained in:
224
server/store/sqlite/memory.go
Normal file
224
server/store/sqlite/memory.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
type MemoryStore struct{}
|
||||
|
||||
func NewMemoryStore() *MemoryStore { return &MemoryStore{} }
|
||||
|
||||
func (s *MemoryStore) Upsert(ctx context.Context, m *models.Memory) error {
|
||||
if m.ID == "" {
|
||||
m.ID = store.NewID()
|
||||
}
|
||||
if m.Status == "" {
|
||||
m.Status = models.MemoryStatusActive
|
||||
}
|
||||
if m.Confidence == 0 {
|
||||
m.Confidence = 1.0
|
||||
}
|
||||
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO memories (id, scope, owner_id, user_id, key, value,
|
||||
source_channel_id, confidence, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (scope, owner_id, COALESCE(user_id, '00000000-0000-0000-0000-000000000000'), key)
|
||||
DO UPDATE SET
|
||||
value = excluded.value,
|
||||
confidence = excluded.confidence,
|
||||
status = excluded.status,
|
||||
source_channel_id = excluded.source_channel_id,
|
||||
updated_at = datetime('now')
|
||||
`, m.ID, m.Scope, m.OwnerID, m.UserID, m.Key, m.Value,
|
||||
m.SourceChannelID, m.Confidence, m.Status)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetByID(ctx context.Context, id string) (*models.Memory, error) {
|
||||
m := &models.Memory{}
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, scope, owner_id, user_id, key, value,
|
||||
source_channel_id, confidence, status, created_at, updated_at
|
||||
FROM memories WHERE id = ?
|
||||
`, id).Scan(
|
||||
&m.ID, &m.Scope, &m.OwnerID, &m.UserID, &m.Key, &m.Value,
|
||||
&m.SourceChannelID, &m.Confidence, &m.Status,
|
||||
st(&m.CreatedAt), st(&m.UpdatedAt),
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("memory not found: %s", id)
|
||||
}
|
||||
return m, err
|
||||
}
|
||||
|
||||
func (s *MemoryStore) List(ctx context.Context, f models.MemoryFilter) ([]models.Memory, error) {
|
||||
clauses := []string{"scope = ?", "owner_id = ?"}
|
||||
args := []interface{}{f.Scope, f.OwnerID}
|
||||
|
||||
if f.UserID != nil {
|
||||
clauses = append(clauses, "user_id = ?")
|
||||
args = append(args, *f.UserID)
|
||||
}
|
||||
|
||||
status := f.Status
|
||||
if status == "" {
|
||||
status = models.MemoryStatusActive
|
||||
}
|
||||
clauses = append(clauses, "status = ?")
|
||||
args = append(args, status)
|
||||
|
||||
if f.Query != "" {
|
||||
// SQLite: LIKE-based keyword search (no tsvector)
|
||||
clauses = append(clauses, "(key || ' ' || value) LIKE ?")
|
||||
args = append(args, "%"+f.Query+"%")
|
||||
}
|
||||
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
args = append(args, limit)
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id, scope, owner_id, user_id, key, value,
|
||||
source_channel_id, confidence, status, created_at, updated_at
|
||||
FROM memories
|
||||
WHERE %s
|
||||
ORDER BY confidence DESC, updated_at DESC
|
||||
LIMIT ?
|
||||
`, strings.Join(clauses, " AND "))
|
||||
|
||||
rows, err := DB.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return s.scanMemories(rows)
|
||||
}
|
||||
|
||||
func (s *MemoryStore) Delete(ctx context.Context, id string) error {
|
||||
_, err := DB.ExecContext(ctx, `DELETE FROM memories WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *MemoryStore) Archive(ctx context.Context, id string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE memories SET status = 'archived', updated_at = datetime('now') WHERE id = ?
|
||||
`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *MemoryStore) Recall(ctx context.Context, userID string, personaID *string, query string, limit int) ([]models.Memory, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
// Build UNION query across applicable scopes
|
||||
parts := []string{}
|
||||
args := []interface{}{}
|
||||
|
||||
// 1. User-scope memories
|
||||
userPart := `
|
||||
SELECT id, scope, owner_id, user_id, key, value,
|
||||
source_channel_id, confidence, status, created_at, updated_at
|
||||
FROM memories
|
||||
WHERE scope = 'user' AND owner_id = ? AND status = 'active'
|
||||
`
|
||||
args = append(args, userID)
|
||||
|
||||
if query != "" {
|
||||
userPart += " AND (key || ' ' || value) LIKE ?"
|
||||
args = append(args, "%"+query+"%")
|
||||
}
|
||||
parts = append(parts, userPart)
|
||||
|
||||
// 2. Persona-scope memories
|
||||
if personaID != nil && *personaID != "" {
|
||||
personaPart := `
|
||||
SELECT id, scope, owner_id, user_id, key, value,
|
||||
source_channel_id, confidence, status, created_at, updated_at
|
||||
FROM memories
|
||||
WHERE scope = 'persona' AND owner_id = ? AND status = 'active'
|
||||
`
|
||||
args = append(args, *personaID)
|
||||
if query != "" {
|
||||
personaPart += " AND (key || ' ' || value) LIKE ?"
|
||||
args = append(args, "%"+query+"%")
|
||||
}
|
||||
parts = append(parts, personaPart)
|
||||
|
||||
// 3. Persona+user memories
|
||||
puPart := `
|
||||
SELECT id, scope, owner_id, user_id, key, value,
|
||||
source_channel_id, confidence, status, created_at, updated_at
|
||||
FROM memories
|
||||
WHERE scope = 'persona_user' AND owner_id = ? AND user_id = ? AND status = 'active'
|
||||
`
|
||||
args = append(args, *personaID, userID)
|
||||
if query != "" {
|
||||
puPart += " AND (key || ' ' || value) LIKE ?"
|
||||
args = append(args, "%"+query+"%")
|
||||
}
|
||||
parts = append(parts, puPart)
|
||||
}
|
||||
|
||||
fullQuery := fmt.Sprintf(`
|
||||
SELECT * FROM (%s)
|
||||
ORDER BY
|
||||
CASE scope
|
||||
WHEN 'persona_user' THEN 0
|
||||
WHEN 'persona' THEN 1
|
||||
WHEN 'user' THEN 2
|
||||
END,
|
||||
confidence DESC,
|
||||
updated_at DESC
|
||||
LIMIT ?
|
||||
`, strings.Join(parts, " UNION ALL "))
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := DB.QueryContext(ctx, fullQuery, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return s.scanMemories(rows)
|
||||
}
|
||||
|
||||
func (s *MemoryStore) CountByOwner(ctx context.Context, scope, ownerID string) (int, error) {
|
||||
var count int
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM memories
|
||||
WHERE scope = ? AND owner_id = ? AND status = 'active'
|
||||
`, scope, ownerID).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
func (s *MemoryStore) scanMemories(rows *sql.Rows) ([]models.Memory, error) {
|
||||
var memories []models.Memory
|
||||
for rows.Next() {
|
||||
var m models.Memory
|
||||
if err := rows.Scan(
|
||||
&m.ID, &m.Scope, &m.OwnerID, &m.UserID, &m.Key, &m.Value,
|
||||
&m.SourceChannelID, &m.Confidence, &m.Status,
|
||||
st(&m.CreatedAt), st(&m.UpdatedAt),
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
memories = append(memories, m)
|
||||
}
|
||||
if memories == nil {
|
||||
memories = []models.Memory{}
|
||||
}
|
||||
return memories, rows.Err()
|
||||
}
|
||||
146
server/store/sqlite/memory_hybrid.go
Normal file
146
server/store/sqlite/memory_hybrid.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
// RecallHybrid returns active memories using vector similarity + keyword search.
|
||||
// SQLite has no pgvector, so we load embeddings and compute cosine similarity in Go.
|
||||
func (s *MemoryStore) RecallHybrid(ctx context.Context, userID string, personaID *string, query string, queryVec []float64, limit int) ([]models.Memory, error) {
|
||||
if len(queryVec) == 0 {
|
||||
return s.Recall(ctx, userID, personaID, query, limit)
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 30
|
||||
}
|
||||
|
||||
// Load all active memories with embeddings for applicable scopes
|
||||
semantic, err := s.recallSemantic(ctx, userID, personaID, queryVec, limit)
|
||||
if err != nil || len(semantic) == 0 {
|
||||
return s.Recall(ctx, userID, personaID, query, limit)
|
||||
}
|
||||
|
||||
var keyword []models.Memory
|
||||
if query != "" {
|
||||
keyword, _ = s.Recall(ctx, userID, personaID, query, limit)
|
||||
}
|
||||
|
||||
return mergeResultsSQLite(semantic, keyword, limit), nil
|
||||
}
|
||||
|
||||
type scoredMemory struct {
|
||||
Memory models.Memory
|
||||
Similarity float64
|
||||
}
|
||||
|
||||
func (s *MemoryStore) recallSemantic(ctx context.Context, userID string, personaID *string, queryVec []float64, limit int) ([]models.Memory, error) {
|
||||
// Build query to load memories with embeddings
|
||||
parts := []string{}
|
||||
args := []interface{}{}
|
||||
|
||||
userPart := `
|
||||
SELECT id, scope, owner_id, user_id, key, value,
|
||||
source_channel_id, confidence, status, created_at, updated_at, embedding
|
||||
FROM memories
|
||||
WHERE scope = 'user' AND owner_id = ? AND status = 'active'
|
||||
AND embedding IS NOT NULL
|
||||
`
|
||||
args = append(args, userID)
|
||||
parts = append(parts, userPart)
|
||||
|
||||
if personaID != nil && *personaID != "" {
|
||||
personaPart := `
|
||||
SELECT id, scope, owner_id, user_id, key, value,
|
||||
source_channel_id, confidence, status, created_at, updated_at, embedding
|
||||
FROM memories
|
||||
WHERE scope = 'persona' AND owner_id = ? AND status = 'active'
|
||||
AND embedding IS NOT NULL
|
||||
`
|
||||
args = append(args, *personaID)
|
||||
parts = append(parts, personaPart)
|
||||
|
||||
puPart := `
|
||||
SELECT id, scope, owner_id, user_id, key, value,
|
||||
source_channel_id, confidence, status, created_at, updated_at, embedding
|
||||
FROM memories
|
||||
WHERE scope = 'persona_user' AND owner_id = ? AND user_id = ? AND status = 'active'
|
||||
AND embedding IS NOT NULL
|
||||
`
|
||||
args = append(args, *personaID, userID)
|
||||
parts = append(parts, puPart)
|
||||
}
|
||||
|
||||
fullQuery := strings.Join(parts, " UNION ALL ")
|
||||
rows, err := DB.QueryContext(ctx, fullQuery, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Score each memory by cosine similarity
|
||||
var scored []scoredMemory
|
||||
for rows.Next() {
|
||||
var m models.Memory
|
||||
var embeddingJSON string
|
||||
if err := rows.Scan(
|
||||
&m.ID, &m.Scope, &m.OwnerID, &m.UserID, &m.Key, &m.Value,
|
||||
&m.SourceChannelID, &m.Confidence, &m.Status,
|
||||
st(&m.CreatedAt), st(&m.UpdatedAt), &embeddingJSON,
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var vec []float64
|
||||
if err := json.Unmarshal([]byte(embeddingJSON), &vec); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// cosineSimilarity is defined in knowledge_bases.go (same package)
|
||||
sim := cosineSimilarity(queryVec, vec)
|
||||
if sim > 0.3 {
|
||||
scored = append(scored, scoredMemory{Memory: m, Similarity: sim})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by similarity descending (simple insertion sort for small N)
|
||||
for i := 0; i < len(scored); i++ {
|
||||
for j := i + 1; j < len(scored); j++ {
|
||||
if scored[j].Similarity > scored[i].Similarity {
|
||||
scored[i], scored[j] = scored[j], scored[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Take top N
|
||||
result := make([]models.Memory, 0, limit)
|
||||
for i := 0; i < len(scored) && i < limit; i++ {
|
||||
result = append(result, scored[i].Memory)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func mergeResultsSQLite(semantic, keyword []models.Memory, limit int) []models.Memory {
|
||||
seen := make(map[string]bool, len(semantic))
|
||||
result := make([]models.Memory, 0, limit)
|
||||
|
||||
for _, m := range semantic {
|
||||
if len(result) >= limit {
|
||||
break
|
||||
}
|
||||
seen[m.ID] = true
|
||||
result = append(result, m)
|
||||
}
|
||||
for _, m := range keyword {
|
||||
if len(result) >= limit {
|
||||
break
|
||||
}
|
||||
if !seen[m.ID] {
|
||||
result = append(result, m)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -17,7 +17,8 @@ func NewPersonaStore() *PersonaStore { return &PersonaStore{} }
|
||||
|
||||
const personaCols = `id, name, description, icon, avatar, base_model_id, provider_config_id,
|
||||
system_prompt, temperature, max_tokens, thinking_budget, top_p,
|
||||
scope, owner_id, created_by, is_active, is_shared, created_at, updated_at`
|
||||
scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt,
|
||||
created_at, updated_at`
|
||||
|
||||
func (s *PersonaStore) Create(ctx context.Context, p *models.Persona) error {
|
||||
p.ID = store.NewID()
|
||||
@@ -27,13 +28,15 @@ func (s *PersonaStore) Create(ctx context.Context, p *models.Persona) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO personas (id, name, description, icon, avatar, base_model_id, provider_config_id,
|
||||
system_prompt, temperature, max_tokens, thinking_budget, top_p,
|
||||
scope, owner_id, created_by, is_active, is_shared, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt,
|
||||
created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
p.ID, p.Name, p.Description, p.Icon, p.Avatar, p.BaseModelID,
|
||||
models.NullString(p.ProviderConfigID),
|
||||
p.SystemPrompt, models.NullFloat(p.Temperature), models.NullInt(p.MaxTokens),
|
||||
models.NullInt(p.ThinkingBudget), models.NullFloat(p.TopP),
|
||||
p.Scope, models.NullString(p.OwnerID), p.CreatedBy, p.IsActive, p.IsShared,
|
||||
p.MemoryEnabled, p.MemoryExtractionPrompt,
|
||||
now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
@@ -93,6 +96,12 @@ func (s *PersonaStore) Update(ctx context.Context, id string, patch models.Perso
|
||||
if patch.IsShared != nil {
|
||||
b.Set("is_shared", *patch.IsShared)
|
||||
}
|
||||
if patch.MemoryEnabled != nil {
|
||||
b.Set("memory_enabled", *patch.MemoryEnabled)
|
||||
}
|
||||
if patch.MemoryExtractionPrompt != nil {
|
||||
b.Set("memory_extraction_prompt", *patch.MemoryExtractionPrompt)
|
||||
}
|
||||
if !b.HasSets() {
|
||||
return nil
|
||||
}
|
||||
@@ -286,6 +295,7 @@ func scanPersona(row *sql.Row) (*models.Persona, error) {
|
||||
&p.BaseModelID, &providerConfigID,
|
||||
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
|
||||
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
|
||||
&p.MemoryEnabled, &p.MemoryExtractionPrompt,
|
||||
st(&p.CreatedAt), st(&p.UpdatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -312,6 +322,7 @@ func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
|
||||
&p.BaseModelID, &providerConfigID,
|
||||
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
|
||||
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
|
||||
&p.MemoryEnabled, &p.MemoryExtractionPrompt,
|
||||
st(&p.CreatedAt), st(&p.UpdatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -31,5 +31,6 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
KnowledgeBases: NewKnowledgeBaseStore(),
|
||||
Groups: NewGroupStore(),
|
||||
ResourceGrants: NewResourceGrantStore(),
|
||||
Memories: NewMemoryStore(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user