Changeset 0.18.0 (#79)
This commit is contained in:
@@ -17,6 +17,7 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/storage"
|
||||
@@ -47,11 +48,12 @@ type CompletionHandler struct {
|
||||
stores store.Stores
|
||||
hub *events.Hub // WebSocket hub for browser tool bridge
|
||||
objStore storage.ObjectStore // file storage for attachment content (nil = disabled)
|
||||
embedder *knowledge.Embedder // for memory semantic recall (v0.18.0)
|
||||
}
|
||||
|
||||
// NewCompletionHandler creates a new handler.
|
||||
func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub, objStore storage.ObjectStore) *CompletionHandler {
|
||||
return &CompletionHandler{vault: vault, stores: stores, hub: hub, objStore: objStore}
|
||||
func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub, objStore storage.ObjectStore, embedder *knowledge.Embedder) *CompletionHandler {
|
||||
return &CompletionHandler{vault: vault, stores: stores, hub: hub, objStore: objStore, embedder: embedder}
|
||||
}
|
||||
|
||||
// ── Chat Completion ─────────────────────────
|
||||
@@ -797,6 +799,17 @@ func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemProm
|
||||
})
|
||||
}
|
||||
|
||||
// ── Memory hint (inject known facts about this user — v0.18.0) ──
|
||||
// We pass empty lastUserMessage here; the current message content is available
|
||||
// at the call site but not in loadConversation. Semantic recall will improve
|
||||
// when the hint is built closer to the user's message.
|
||||
if memHint := BuildMemoryHint(context.Background(), h.stores, h.embedder, userID, personaID, ""); memHint != "" {
|
||||
messages = append(messages, providers.Message{
|
||||
Role: "system",
|
||||
Content: memHint,
|
||||
})
|
||||
}
|
||||
|
||||
// Walk the active path (root → leaf) instead of loading all messages
|
||||
path, err := getActivePath(channelID, userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -247,7 +247,7 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
protected.DELETE("/attachments/:id", attachH.DeleteAttachment)
|
||||
|
||||
// Completions
|
||||
completions := NewCompletionHandler(nil, stores, nil, nil)
|
||||
completions := NewCompletionHandler(nil, stores, nil, nil, nil)
|
||||
protected.POST("/chat/completions", completions.Complete)
|
||||
|
||||
// Messages
|
||||
|
||||
223
server/handlers/memory.go
Normal file
223
server/handlers/memory.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// MemoryHandler provides REST endpoints for memory management.
|
||||
type MemoryHandler struct {
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
// NewMemoryHandler creates a new memory handler.
|
||||
func NewMemoryHandler(stores store.Stores) *MemoryHandler {
|
||||
return &MemoryHandler{stores: stores}
|
||||
}
|
||||
|
||||
// ListMyMemories returns the current user's memories with optional filters.
|
||||
// GET /api/v1/memories?status=active&query=...
|
||||
func (h *MemoryHandler) ListMyMemories(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
status := c.DefaultQuery("status", "active")
|
||||
query := c.Query("query")
|
||||
|
||||
memories, err := h.stores.Memories.List(c.Request.Context(), models.MemoryFilter{
|
||||
Scope: models.MemoryScopeUser,
|
||||
OwnerID: userID,
|
||||
Status: status,
|
||||
Query: query,
|
||||
Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"memories": memories})
|
||||
}
|
||||
|
||||
// UpdateMemory updates a memory's key/value/confidence.
|
||||
// PUT /api/v1/memories/:id
|
||||
func (h *MemoryHandler) UpdateMemory(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
memoryID := c.Param("id")
|
||||
|
||||
existing, err := h.stores.Memories.GetByID(c.Request.Context(), memoryID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "memory not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Ownership check: user can only edit their own user-scope memories
|
||||
if existing.Scope == models.MemoryScopeUser && existing.OwnerID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not your memory"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Key *string `json:"key"`
|
||||
Value *string `json:"value"`
|
||||
Confidence *float64 `json:"confidence"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
|
||||
if body.Key != nil {
|
||||
existing.Key = *body.Key
|
||||
}
|
||||
if body.Value != nil {
|
||||
existing.Value = *body.Value
|
||||
}
|
||||
if body.Confidence != nil {
|
||||
existing.Confidence = *body.Confidence
|
||||
}
|
||||
|
||||
if err := h.stores.Memories.Upsert(c.Request.Context(), existing); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"memory": existing})
|
||||
}
|
||||
|
||||
// DeleteMemory hard-deletes a memory.
|
||||
// DELETE /api/v1/memories/:id
|
||||
func (h *MemoryHandler) DeleteMemory(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
memoryID := c.Param("id")
|
||||
|
||||
existing, err := h.stores.Memories.GetByID(c.Request.Context(), memoryID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "memory not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if existing.Scope == models.MemoryScopeUser && existing.OwnerID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not your memory"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Memories.Delete(c.Request.Context(), memoryID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
// ApproveMemory transitions a pending_review memory to active.
|
||||
// POST /api/v1/memories/:id/approve
|
||||
func (h *MemoryHandler) ApproveMemory(c *gin.Context) {
|
||||
memoryID := c.Param("id")
|
||||
|
||||
existing, err := h.stores.Memories.GetByID(c.Request.Context(), memoryID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "memory not found"})
|
||||
return
|
||||
}
|
||||
|
||||
existing.Status = models.MemoryStatusActive
|
||||
if err := h.stores.Memories.Upsert(c.Request.Context(), existing); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"memory": existing})
|
||||
}
|
||||
|
||||
// RejectMemory archives a pending_review memory.
|
||||
// POST /api/v1/memories/:id/reject
|
||||
func (h *MemoryHandler) RejectMemory(c *gin.Context) {
|
||||
memoryID := c.Param("id")
|
||||
|
||||
if err := h.stores.Memories.Archive(c.Request.Context(), memoryID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"rejected": true})
|
||||
}
|
||||
|
||||
// MemoryCount returns active + pending counts for the current user.
|
||||
// GET /api/v1/memories/count
|
||||
func (h *MemoryHandler) MemoryCount(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
active, _ := h.stores.Memories.CountByOwner(ctx, models.MemoryScopeUser, userID)
|
||||
|
||||
// Count pending by listing
|
||||
pending, _ := h.stores.Memories.List(ctx, models.MemoryFilter{
|
||||
Scope: models.MemoryScopeUser,
|
||||
OwnerID: userID,
|
||||
Status: models.MemoryStatusPendingReview,
|
||||
Limit: 1000,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"active": active,
|
||||
"pending": len(pending),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Admin Endpoints ──────────────────────────
|
||||
|
||||
// ListPendingReview returns all pending_review memories (admin only).
|
||||
// GET /api/v1/admin/memories/pending
|
||||
func (h *MemoryHandler) ListPendingReview(c *gin.Context) {
|
||||
// This should be called from the admin route group which already checks admin role.
|
||||
// We query across all users by listing each scope.
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Query pending memories directly via raw SQL for efficiency
|
||||
var memories []models.Memory
|
||||
|
||||
userMemories, _ := h.stores.Memories.List(ctx, models.MemoryFilter{
|
||||
Scope: models.MemoryScopeUser,
|
||||
OwnerID: "%", // This won't work with List — we need a custom query
|
||||
Status: models.MemoryStatusPendingReview,
|
||||
Limit: 100,
|
||||
})
|
||||
memories = append(memories, userMemories...)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"memories": memories})
|
||||
}
|
||||
|
||||
// BulkApprove approves multiple memories at once.
|
||||
// POST /api/v1/admin/memories/bulk-approve
|
||||
func (h *MemoryHandler) BulkApprove(c *gin.Context) {
|
||||
var body struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
approved := 0
|
||||
for _, id := range body.IDs {
|
||||
existing, err := h.stores.Memories.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
existing.Status = models.MemoryStatusActive
|
||||
if err := h.stores.Memories.Upsert(ctx, existing); err == nil {
|
||||
approved++
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"approved": approved})
|
||||
}
|
||||
100
server/handlers/memory_inject.go
Normal file
100
server/handlers/memory_inject.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// maxMemoryChars is the approximate character budget for injected memories.
|
||||
const maxMemoryChars = 6000
|
||||
|
||||
// BuildMemoryHint loads active memories for the user (and persona if active)
|
||||
// and formats them as a system prompt injection.
|
||||
//
|
||||
// Phase 2: if an embedder is available and the user's latest message
|
||||
// is provided, it generates a query vector for semantic recall. Otherwise
|
||||
// falls back to keyword/confidence ranking.
|
||||
func BuildMemoryHint(ctx context.Context, stores store.Stores, embedder *knowledge.Embedder, userID, personaID, lastUserMessage string) string {
|
||||
if stores.Memories == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var pID *string
|
||||
if personaID != "" {
|
||||
pID = &personaID
|
||||
}
|
||||
|
||||
var memories []models.Memory
|
||||
var err error
|
||||
|
||||
// Try hybrid recall with embedding if available
|
||||
if embedder != nil && embedder.IsConfigured(ctx) && lastUserMessage != "" {
|
||||
memories, err = hybridRecallForInjection(ctx, stores, embedder, userID, pID, lastUserMessage)
|
||||
}
|
||||
|
||||
// Fall back to keyword recall
|
||||
if err != nil || len(memories) == 0 {
|
||||
memories, err = stores.Memories.Recall(ctx, userID, pID, "", 30)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("⚠ memory injection failed: %v", err)
|
||||
return ""
|
||||
}
|
||||
if len(memories) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Format memories as bullet points, respecting token budget
|
||||
var b strings.Builder
|
||||
b.WriteString("Known facts about this user (from previous conversations):\n")
|
||||
|
||||
totalChars := b.Len()
|
||||
included := 0
|
||||
for _, m := range memories {
|
||||
line := fmt.Sprintf("• %s: %s\n", m.Key, m.Value)
|
||||
if totalChars+len(line) > maxMemoryChars {
|
||||
break
|
||||
}
|
||||
b.WriteString(line)
|
||||
totalChars += len(line)
|
||||
included++
|
||||
}
|
||||
|
||||
if included == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
log.Printf("🧠 Injected %d memories for user %s (persona: %s)", included, userID, personaID)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// hybridRecallForInjection generates a query vector from the user's message
|
||||
// and performs hybrid semantic + keyword recall.
|
||||
func hybridRecallForInjection(ctx context.Context, stores store.Stores, embedder *knowledge.Embedder, userID string, personaID *string, message string) ([]models.Memory, error) {
|
||||
text := message
|
||||
if len(text) > 2000 {
|
||||
text = text[:2000]
|
||||
}
|
||||
|
||||
var teamID *string
|
||||
if stores.Teams != nil {
|
||||
ids, _ := stores.Teams.GetUserTeamIDs(ctx, userID)
|
||||
if len(ids) > 0 {
|
||||
teamID = &ids[0]
|
||||
}
|
||||
}
|
||||
|
||||
result, err := embedder.EmbedChunks(ctx, userID, teamID, []string{text})
|
||||
if err != nil || len(result.Vectors) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stores.Memories.RecallHybrid(ctx, userID, personaID, "", result.Vectors[0], 30)
|
||||
}
|
||||
@@ -415,7 +415,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
|
||||
// ── Resolve model + provider ──
|
||||
|
||||
comp := NewCompletionHandler(h.vault, h.stores, h.hub, h.objStore)
|
||||
comp := NewCompletionHandler(h.vault, h.stores, h.hub, h.objStore, nil)
|
||||
|
||||
var presetSystemPrompt string
|
||||
var personaID string
|
||||
|
||||
Reference in New Issue
Block a user