This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/knowledge_bases.go
2026-03-19 18:50:27 +00:00

1045 lines
29 KiB
Go

package handlers
import (
"context"
"fmt"
"io"
"log"
"net/http"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"chat-switchboard/knowledge"
"chat-switchboard/models"
"chat-switchboard/storage"
"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,
})
}