package handlers import ( "context" "fmt" "io" "log" "net/http" "path/filepath" "strings" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/knowledge" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/storage" "git.gobha.me/xcaliber/chat-switchboard/store" ) // ── Limits ─────────────────────────────────── const ( kbMaxDocSize = 10 * 1024 * 1024 // 10 MB per document kbMaxNameLen = 200 kbMaxDescLen = 2000 ) // allowedKBMIMETypes lists content types accepted for KB documents. var allowedKBMIMETypes = map[string]bool{ "text/plain": true, "text/markdown": true, "text/csv": true, "text/html": true, // Future: PDF, docx once extraction sidecar is wired in } // ── Request / Response Types ───────────────── type createKBRequest struct { Name string `json:"name" binding:"required"` Description string `json:"description"` Scope string `json:"scope"` // personal (default), team, global TeamID string `json:"team_id"` } type updateKBRequest struct { Name *string `json:"name"` Description *string `json:"description"` } type kbResponse struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Scope string `json:"scope"` OwnerID *string `json:"owner_id,omitempty"` TeamID *string `json:"team_id,omitempty"` EmbeddingConfig models.JSONMap `json:"embedding_config"` DocumentCount int `json:"document_count"` ChunkCount int `json:"chunk_count"` TotalBytes int64 `json:"total_bytes"` Status string `json:"status"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } type docResponse struct { ID string `json:"id"` KBID string `json:"kb_id"` Filename string `json:"filename"` ContentType string `json:"content_type"` SizeBytes int64 `json:"size_bytes"` ChunkCount int `json:"chunk_count"` Status string `json:"status"` Error *string `json:"error,omitempty"` UploadedBy string `json:"uploaded_by"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } type searchKBRequest struct { Query string `json:"query" binding:"required"` Limit int `json:"limit"` Threshold float64 `json:"threshold"` } // ── Handler ────────────────────────────────── // KnowledgeBaseHandler handles KB CRUD and document management. type KnowledgeBaseHandler struct { stores store.Stores objStore storage.ObjectStore ingester *knowledge.Ingester embedder *knowledge.Embedder } // NewKnowledgeBaseHandler creates a KB handler. func NewKnowledgeBaseHandler( stores store.Stores, objStore storage.ObjectStore, ingester *knowledge.Ingester, embedder *knowledge.Embedder, ) *KnowledgeBaseHandler { return &KnowledgeBaseHandler{ stores: stores, objStore: objStore, ingester: ingester, embedder: embedder, } } // ── KB CRUD ────────────────────────────────── // CreateKB creates a new knowledge base. // POST /api/v1/knowledge-bases func (h *KnowledgeBaseHandler) CreateKB(c *gin.Context) { var req createKBRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if len(req.Name) > kbMaxNameLen { c.JSON(http.StatusBadRequest, gin.H{"error": "name too long"}) return } if len(req.Description) > kbMaxDescLen { c.JSON(http.StatusBadRequest, gin.H{"error": "description too long"}) return } userID := getUserID(c) // Default scope scope := "personal" if req.Scope != "" { scope = req.Scope } // Validate scope switch scope { case "personal": // OK — owned by user case "team": if req.TeamID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "team_id required for team scope"}) return } // Verify user is team admin (or system admin) role, _ := c.Get("role") if role != "admin" { isTA, err := h.stores.Teams.IsTeamAdmin(c.Request.Context(), req.TeamID, userID) if err != nil || !isTA { c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required to create team knowledge bases"}) return } } case "global": // Verify user is system admin role, _ := c.Get("role") if role != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "admin access required to create global knowledge bases"}) return } default: c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be personal, team, or global"}) return } kb := &models.KnowledgeBase{ Name: req.Name, Description: req.Description, Scope: scope, EmbeddingConfig: models.JSONMap{}, Status: "active", } // Set ownership based on scope switch scope { case "personal": kb.OwnerID = &userID case "team": kb.TeamID = &req.TeamID } if err := h.stores.KnowledgeBases.Create(c.Request.Context(), kb); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create knowledge base"}) return } h.auditLog(c, "kb.create", kb.ID, map[string]interface{}{ "name": kb.Name, "scope": kb.Scope, }) c.JSON(http.StatusCreated, toKBResponse(kb)) } // ListKBs lists knowledge bases accessible to the current user. // GET /api/v1/knowledge-bases func (h *KnowledgeBaseHandler) ListKBs(c *gin.Context) { userID := getUserID(c) // Get user's team IDs for scoped listing teamIDs := h.getUserTeamIDs(c, userID) kbs, err := h.stores.KnowledgeBases.ListForUser(c.Request.Context(), userID, teamIDs) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list knowledge bases"}) return } items := make([]kbResponse, len(kbs)) for i := range kbs { items[i] = toKBResponse(&kbs[i]) } c.JSON(http.StatusOK, gin.H{"data": items, "total": len(items)}) } // GetKB returns a single knowledge base. // GET /api/v1/knowledge-bases/:id func (h *KnowledgeBaseHandler) GetKB(c *gin.Context) { kb, ok := h.loadAndAuthorize(c) if !ok { return } c.JSON(http.StatusOK, toKBResponse(kb)) } // UpdateKB updates a knowledge base's name/description. // PUT /api/v1/knowledge-bases/:id func (h *KnowledgeBaseHandler) UpdateKB(c *gin.Context) { kb, ok := h.loadAndAuthorize(c) if !ok { return } var req updateKBRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } fields := make(map[string]interface{}) if req.Name != nil { if len(*req.Name) > kbMaxNameLen { c.JSON(http.StatusBadRequest, gin.H{"error": "name too long"}) return } fields["name"] = *req.Name } if req.Description != nil { if len(*req.Description) > kbMaxDescLen { c.JSON(http.StatusBadRequest, gin.H{"error": "description too long"}) return } fields["description"] = *req.Description } if len(fields) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return } if err := h.stores.KnowledgeBases.Update(c.Request.Context(), kb.ID, fields); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update knowledge base"}) return } // Reload for response updated, err := h.stores.KnowledgeBases.GetByID(c.Request.Context(), kb.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload knowledge base"}) return } c.JSON(http.StatusOK, toKBResponse(updated)) } // DeleteKB deletes a knowledge base and all its documents/chunks. // DELETE /api/v1/knowledge-bases/:id func (h *KnowledgeBaseHandler) DeleteKB(c *gin.Context) { kb, ok := h.loadAndAuthorize(c) if !ok { return } // Delete stored files if h.objStore != nil { prefix := fmt.Sprintf("kb/%s/", kb.ID) _ = h.objStore.DeletePrefix(c.Request.Context(), prefix) } // Delete from DB (cascades to documents and chunks) if err := h.stores.KnowledgeBases.Delete(c.Request.Context(), kb.ID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete knowledge base"}) return } h.auditLog(c, "kb.delete", kb.ID, map[string]interface{}{ "name": kb.Name, "scope": kb.Scope, }) c.JSON(http.StatusOK, gin.H{"deleted": true}) } // ── Document Management ────────────────────── // UploadDocument uploads a document to a KB and starts async ingestion. // POST /api/v1/knowledge-bases/:id/documents func (h *KnowledgeBaseHandler) UploadDocument(c *gin.Context) { if h.objStore == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"}) return } kb, ok := h.loadAndAuthorize(c) if !ok { return } // Verify embedding role is configured if !h.embedder.IsConfigured(c.Request.Context()) { c.JSON(http.StatusPreconditionFailed, gin.H{ "error": "embedding model role not configured — set up an embedding provider in Settings → Model Roles", }) return } // Parse multipart file, header, err := c.Request.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"}) return } defer file.Close() if header.Size > kbMaxDocSize { c.JSON(http.StatusBadRequest, gin.H{ "error": fmt.Sprintf("file too large (max %d MB)", kbMaxDocSize/(1024*1024)), }) return } // MIME detection buf := make([]byte, 512) n, _ := file.Read(buf) contentType := http.DetectContentType(buf[:n]) if idx := strings.Index(contentType, ";"); idx > 0 { contentType = strings.TrimSpace(contentType[:idx]) } // Fall back to extension for octet-stream if contentType == "application/octet-stream" { ext := strings.ToLower(filepath.Ext(header.Filename)) switch ext { case ".md", ".markdown": contentType = "text/markdown" case ".csv": contentType = "text/csv" case ".html", ".htm": contentType = "text/html" case ".txt": contentType = "text/plain" } } // Allowlist check if !allowedKBMIMETypes[contentType] { c.JSON(http.StatusBadRequest, gin.H{ "error": fmt.Sprintf("file type %q not supported for knowledge bases — only text, markdown, CSV, and HTML are currently supported", contentType), }) return } // Reset reader if _, err := file.Seek(0, io.SeekStart); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process file"}) return } userID := getUserID(c) filename := sanitizeFilename(header.Filename) // Create document record (status=pending) doc := &models.KBDocument{ KBID: kb.ID, Filename: filename, ContentType: contentType, SizeBytes: header.Size, StorageKey: "placeholder", Status: "pending", UploadedBy: userID, } if err := h.stores.KnowledgeBases.CreateDocument(c.Request.Context(), doc); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create document record"}) return } // Build storage key and save file doc.StorageKey = fmt.Sprintf("kb/%s/%s_%s", kb.ID, doc.ID, filename) if err := h.objStore.Put(c.Request.Context(), doc.StorageKey, file, header.Size, contentType); err != nil { // Rollback DB record h.stores.KnowledgeBases.DeleteDocument(c.Request.Context(), doc.ID) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"}) return } // Update storage key in DB h.updateDocStorageKey(c, doc.ID, doc.StorageKey) // Resolve team ID for embedding role var teamID *string if kb.TeamID != nil { teamID = kb.TeamID } // Fire async ingestion h.ingester.IngestDocument(kb, doc, userID, teamID) h.auditLog(c, "kb.document.upload", kb.ID, map[string]interface{}{ "kb_name": kb.Name, "doc_id": doc.ID, "filename": doc.Filename, "size_bytes": doc.SizeBytes, }) c.JSON(http.StatusAccepted, toDocResponse(doc)) } // ListDocuments lists all documents in a KB. // GET /api/v1/knowledge-bases/:id/documents func (h *KnowledgeBaseHandler) ListDocuments(c *gin.Context) { kb, ok := h.loadAndAuthorize(c) if !ok { return } docs, err := h.stores.KnowledgeBases.ListDocuments(c.Request.Context(), kb.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list documents"}) return } items := make([]docResponse, len(docs)) for i := range docs { items[i] = toDocResponse(&docs[i]) } c.JSON(http.StatusOK, gin.H{"data": items, "total": len(items)}) } // GetDocumentStatus returns the current ingestion status of a document. // GET /api/v1/knowledge-bases/:id/documents/:docId/status func (h *KnowledgeBaseHandler) GetDocumentStatus(c *gin.Context) { kb, ok := h.loadAndAuthorize(c) if !ok { return } docID := c.Param("docId") doc, err := h.stores.KnowledgeBases.GetDocument(c.Request.Context(), docID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "document not found"}) return } if doc.KBID != kb.ID { c.JSON(http.StatusNotFound, gin.H{"error": "document not found"}) return } c.JSON(http.StatusOK, gin.H{ "id": doc.ID, "status": doc.Status, "chunk_count": doc.ChunkCount, "error": doc.Error, "filename": doc.Filename, }) } // DeleteDocument removes a document and its chunks. // DELETE /api/v1/knowledge-bases/:id/documents/:docId func (h *KnowledgeBaseHandler) DeleteDocument(c *gin.Context) { kb, ok := h.loadAndAuthorize(c) if !ok { return } docID := c.Param("docId") doc, err := h.stores.KnowledgeBases.GetDocument(c.Request.Context(), docID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "document not found"}) return } if doc.KBID != kb.ID { c.JSON(http.StatusNotFound, gin.H{"error": "document not found"}) return } // Delete chunks first h.stores.KnowledgeBases.DeleteChunksForDocument(c.Request.Context(), docID) // Delete the document record (returns doc for storage cleanup) deleted, err := h.stores.KnowledgeBases.DeleteDocument(c.Request.Context(), docID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete document"}) return } // Clean up stored file if h.objStore != nil && deleted != nil && deleted.StorageKey != "" && deleted.StorageKey != "placeholder" { _ = h.objStore.Delete(c.Request.Context(), deleted.StorageKey) } // Update KB stats h.stores.KnowledgeBases.UpdateStats(c.Request.Context(), kb.ID) h.auditLog(c, "kb.document.delete", kb.ID, map[string]interface{}{ "kb_name": kb.Name, "doc_id": docID, "filename": safeFilename(deleted), }) c.JSON(http.StatusOK, gin.H{"deleted": true}) } // ── Search ─────────────────────────────────── // SearchKB performs a direct similarity search against a KB. // POST /api/v1/knowledge-bases/:id/search func (h *KnowledgeBaseHandler) SearchKB(c *gin.Context) { kb, ok := h.loadAndAuthorize(c) if !ok { return } var req searchKBRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Limit <= 0 || req.Limit > 20 { req.Limit = 5 } if req.Threshold <= 0 { req.Threshold = 0.3 // default cosine similarity threshold } userID := getUserID(c) var teamID *string if kb.TeamID != nil { teamID = kb.TeamID } // Embed the query embedResult, err := h.embedder.EmbedChunks(c.Request.Context(), userID, teamID, []string{req.Query}) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to embed query"}) return } if len(embedResult.Vectors) == 0 { c.JSON(http.StatusInternalServerError, gin.H{"error": "embedding produced no vectors"}) return } // Search results, err := h.stores.KnowledgeBases.SimilaritySearch( c.Request.Context(), []string{kb.ID}, embedResult.Vectors[0], req.Threshold, req.Limit, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"}) return } c.JSON(http.StatusOK, gin.H{ "data": results, "query": req.Query, "total": len(results), }) } // ── Helpers ────────────────────────────────── // loadAndAuthorize loads a KB by ID from the URL and checks the user // has access to it (owner, team member, or admin for global). func (h *KnowledgeBaseHandler) loadAndAuthorize(c *gin.Context) (*models.KnowledgeBase, bool) { kbID := c.Param("id") if kbID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing knowledge base ID"}) return nil, false } kb, err := h.stores.KnowledgeBases.GetByID(c.Request.Context(), kbID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "knowledge base not found"}) return nil, false } userID := getUserID(c) switch kb.Scope { case "personal": if kb.OwnerID == nil || *kb.OwnerID != userID { c.JSON(http.StatusNotFound, gin.H{"error": "knowledge base not found"}) return nil, false } case "team": // Verify user is a member of the team teamIDs := h.getUserTeamIDs(c, userID) found := false for _, tid := range teamIDs { if kb.TeamID != nil && tid == *kb.TeamID { found = true break } } if !found { c.JSON(http.StatusNotFound, gin.H{"error": "knowledge base not found"}) return nil, false } case "global": // Global KBs are readable by everyone, writable by admins // For now, allow all authenticated users to read } return kb, true } // getUserTeamIDs returns the team IDs for the current user. func (h *KnowledgeBaseHandler) getUserTeamIDs(c *gin.Context, userID string) []string { ids, err := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID) if err != nil { return nil } return ids } // updateDocStorageKey sets the storage_key on a document after upload. func (h *KnowledgeBaseHandler) updateDocStorageKey(c *gin.Context, docID, storageKey string) { h.stores.KnowledgeBases.UpdateDocumentStorageKey(c.Request.Context(), docID, storageKey) } // toKBResponse converts a model to the API response type. func toKBResponse(kb *models.KnowledgeBase) kbResponse { return kbResponse{ ID: kb.ID, Name: kb.Name, Description: kb.Description, Scope: kb.Scope, OwnerID: kb.OwnerID, TeamID: kb.TeamID, EmbeddingConfig: kb.EmbeddingConfig, DocumentCount: kb.DocumentCount, ChunkCount: kb.ChunkCount, TotalBytes: kb.TotalBytes, Status: kb.Status, CreatedAt: kb.CreatedAt.Format("2006-01-02T15:04:05Z"), UpdatedAt: kb.UpdatedAt.Format("2006-01-02T15:04:05Z"), } } // toDocResponse converts a document model to the API response type. func toDocResponse(doc *models.KBDocument) docResponse { return docResponse{ ID: doc.ID, KBID: doc.KBID, Filename: doc.Filename, ContentType: doc.ContentType, SizeBytes: doc.SizeBytes, ChunkCount: doc.ChunkCount, Status: doc.Status, Error: doc.Error, UploadedBy: doc.UploadedBy, CreatedAt: doc.CreatedAt.Format("2006-01-02T15:04:05Z"), UpdatedAt: doc.UpdatedAt.Format("2006-01-02T15:04:05Z"), } } // ── Channel KB Toggle ──────────────────────── // GetChannelKBs returns knowledge bases linked to a channel. // GET /api/v1/channels/:id/knowledge-bases func (h *KnowledgeBaseHandler) GetChannelKBs(c *gin.Context) { channelID := c.Param("id") userID := getUserID(c) if !userOwnsChannel(c, channelID, userID) { return } kbs, err := h.stores.KnowledgeBases.GetChannelKBs(c.Request.Context(), channelID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get channel knowledge bases"}) return } if kbs == nil { kbs = []models.ChannelKB{} } c.JSON(http.StatusOK, gin.H{"data": kbs}) } // SetChannelKBs links knowledge bases to a channel (replaces existing links). // PUT /api/v1/channels/:id/knowledge-bases // Body: { "kb_ids": ["uuid1", "uuid2"] } func (h *KnowledgeBaseHandler) SetChannelKBs(c *gin.Context) { channelID := c.Param("id") userID := getUserID(c) if !userOwnsChannel(c, channelID, userID) { return } var req struct { KBIDs []string `json:"kb_ids" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Validate that user has access to all requested KBs teamIDs := h.getUserTeamIDs(c, userID) for _, kbID := range req.KBIDs { kb, err := h.stores.KnowledgeBases.GetByID(c.Request.Context(), kbID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("knowledge base %s not found", kbID)}) return } if !h.userCanAccess(kb, userID, teamIDs) { c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("no access to knowledge base %s", kbID)}) return } } if err := h.stores.KnowledgeBases.SetChannelKBs(c.Request.Context(), channelID, req.KBIDs); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel knowledge bases"}) return } // Return the updated list kbs, _ := h.stores.KnowledgeBases.GetChannelKBs(c.Request.Context(), channelID) if kbs == nil { kbs = []models.ChannelKB{} } h.auditLog(c, "kb.channel.link", channelID, map[string]interface{}{ "channel_id": channelID, "kb_ids": req.KBIDs, }) c.JSON(http.StatusOK, gin.H{"data": kbs}) } // ── Discoverable Management (v0.17.0) ──────────── // SetDiscoverable toggles KB visibility to users. // Only the KB owner or a system admin may change this flag. func (h *KnowledgeBaseHandler) SetDiscoverable(c *gin.Context) { kb, ok := h.loadAndAuthorize(c) if !ok { return } // Require ownership or admin role to toggle discoverability userID := getUserID(c) role, _ := c.Get("role") isAdmin := role == "admin" isOwner := kb.OwnerID != nil && *kb.OwnerID == userID if !isAdmin && !isOwner { c.JSON(http.StatusForbidden, gin.H{"error": "only the KB owner or a system admin can change discoverability"}) return } var req struct { Discoverable bool `json:"discoverable"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.stores.KnowledgeBases.SetDiscoverable(c.Request.Context(), kb.ID, req.Discoverable); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update discoverability"}) return } h.auditLog(c, "kb.discoverable", kb.ID, map[string]interface{}{ "kb_name": kb.Name, "discoverable": req.Discoverable, }) c.JSON(http.StatusOK, gin.H{"status": "ok"}) } // ListDiscoverableKBs returns KBs the user can see that are marked discoverable. // Used by the channel KB toggle popup when kb_direct_access is enabled. func (h *KnowledgeBaseHandler) ListDiscoverableKBs(c *gin.Context) { userID := getUserID(c) teamIDs := h.getUserTeamIDs(c, userID) kbs, err := h.stores.KnowledgeBases.ListDiscoverable(c.Request.Context(), userID, teamIDs) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list knowledge bases"}) return } if kbs == nil { kbs = []models.KnowledgeBase{} } items := make([]kbResponse, len(kbs)) for i := range kbs { items[i] = toKBResponse(&kbs[i]) } c.JSON(http.StatusOK, gin.H{"data": items}) } // userCanAccess checks if a user can access a KB based on scope. func (h *KnowledgeBaseHandler) userCanAccess(kb *models.KnowledgeBase, userID string, teamIDs []string) bool { switch kb.Scope { case "global": return true case "personal": return kb.OwnerID != nil && *kb.OwnerID == userID case "team": if kb.TeamID == nil { return false } for _, tid := range teamIDs { if tid == *kb.TeamID { return true } } return false } return false } // ── KB Hint for System Prompt ──────────────── // BuildKBHint returns a system prompt fragment listing active KBs for a // channel, or empty string if none are active. Called by the completion // handler to nudge the LLM to use kb_search. // // Deprecated: v0.29.0 — replaced by filters.KBInjectFilter in the // pre-completion filter chain. Kept for backward compatibility with // any callers outside the main completion path. Will be removed in v0.30.0. func BuildKBHint(ctx context.Context, stores store.Stores, channelID, userID, personaID string) string { teamIDs, _ := stores.Teams.GetUserTeamIDs(ctx, userID) // Collect KB details for the hint type kbInfo struct { Name string DocCount int } var kbs []kbInfo seen := make(map[string]bool) // Persona-bound KBs (v0.17.0) if personaID != "" { personaKBs, err := stores.Personas.GetKBs(ctx, personaID) if err == nil { for _, pkb := range personaKBs { if pkb.ChunkCount > 0 { kbs = append(kbs, kbInfo{Name: pkb.KBName, DocCount: pkb.DocumentCount}) seen[pkb.KBID] = true } } } } // Project-bound KBs (v0.19.0) if stores.Projects != nil { projID, _ := stores.Projects.GetProjectIDForChannel(ctx, channelID) if projID != "" { projKBIDs, projErr := stores.Projects.GetKBIDs(ctx, projID) if projErr == nil { for _, kbID := range projKBIDs { if !seen[kbID] { kb, kbErr := stores.KnowledgeBases.GetByID(ctx, kbID) if kbErr == nil && kb.ChunkCount > 0 { kbs = append(kbs, kbInfo{Name: kb.Name, DocCount: kb.DocumentCount}) seen[kbID] = true } } } } } } // Channel-linked KBs channelKBs, err := stores.KnowledgeBases.GetChannelKBs(ctx, channelID) if err == nil { for _, ckb := range channelKBs { if ckb.Enabled && ckb.DocumentCount > 0 && !seen[ckb.KBID] { kbs = append(kbs, kbInfo{Name: ckb.KBName, DocCount: ckb.DocumentCount}) seen[ckb.KBID] = true } } } // Personal KBs (not already counted) personalKBs, err := stores.KnowledgeBases.ListPersonal(ctx, userID) if err == nil { for _, kb := range personalKBs { if !seen[kb.ID] && kb.ChunkCount > 0 { kbs = append(kbs, kbInfo{Name: kb.Name, DocCount: kb.DocumentCount}) seen[kb.ID] = true } } } // Group-granted KBs are resolved via GetActiveKBIDs — only add names // for KBs found through channel link or persona. The kb_search tool // handles the full resolution at search time. _ = teamIDs // used by GetActiveKBIDsWithPersona at search time if len(kbs) == 0 { return "" } hint := "\nYou have access to the following knowledge bases:\n" for _, kb := range kbs { hint += fmt.Sprintf("- \"%s\" (%d documents)\n", kb.Name, kb.DocCount) } hint += "Use the kb_search tool to find relevant information from these sources when the user asks questions that might be answered by their documents." return hint } // ── Audit Logging ────────────────────────────── // safeFilename safely extracts the filename from a deleted document. func safeFilename(doc *models.KBDocument) string { if doc == nil { return "" } return doc.Filename } // auditLog records a KB operation in the audit log. func (h *KnowledgeBaseHandler) auditLog(c *gin.Context, action, resourceID string, metadata map[string]interface{}) { if h.stores.Audit == nil { return } userID := getUserID(c) var meta models.JSONMap if metadata != nil { meta = models.JSONMap(metadata) } entry := &models.AuditEntry{ ActorID: &userID, Action: action, ResourceType: "knowledge_base", ResourceID: resourceID, Metadata: meta, IPAddress: c.ClientIP(), UserAgent: c.GetHeader("User-Agent"), } if err := h.stores.Audit.Log(context.Background(), entry); err != nil { log.Printf("audit log error (KB): %v", err) } } // ── KB Rebuild ───────────────────────────────── // RebuildKB re-chunks and re-embeds all documents in a knowledge base. // POST /api/v1/knowledge-bases/:id/rebuild func (h *KnowledgeBaseHandler) RebuildKB(c *gin.Context) { if h.ingester == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "ingestion pipeline not configured"}) return } kb, ok := h.loadAndAuthorize(c) if !ok { return } ctx := c.Request.Context() userID := getUserID(c) // List all documents in the KB docs, err := h.stores.KnowledgeBases.ListDocuments(ctx, kb.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list documents"}) return } if len(docs) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "knowledge base has no documents to rebuild"}) return } // Resolve team for embedding role teamIDs := h.getUserTeamIDs(c, userID) var teamID *string if len(teamIDs) > 0 { teamID = &teamIDs[0] } // Delete all existing chunks (embeddings will be regenerated) rebuiltCount := 0 for _, doc := range docs { if doc.ExtractedText == nil || *doc.ExtractedText == "" { // Skip docs without extracted text — they'd need re-extraction // which requires the original file in object store continue } // Clear existing chunks for this document if err := h.stores.KnowledgeBases.DeleteChunksForDocument(ctx, doc.ID); err != nil { log.Printf("⚠ rebuild: failed to delete chunks for doc %s: %v", doc.ID, err) continue } // Reset document status and re-ingest h.stores.KnowledgeBases.UpdateDocumentStatus(ctx, doc.ID, "pending", nil) h.ingester.IngestDocument(kb, &doc, userID, teamID) rebuiltCount++ } h.auditLog(c, "kb.rebuild", kb.ID, map[string]interface{}{ "kb_name": kb.Name, "total_docs": len(docs), "rebuilt_docs": rebuiltCount, }) c.JSON(http.StatusAccepted, gin.H{ "message": "rebuild started", "total_docs": len(docs), "rebuilding": rebuiltCount, "skipped": len(docs) - rebuiltCount, }) }