Changeset 0.14.0 (#67)
This commit is contained in:
950
server/handlers/knowledge_bases.go
Normal file
950
server/handlers/knowledge_bases.go
Normal file
@@ -0,0 +1,950 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"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
|
||||
}
|
||||
// TODO: verify user is team admin
|
||||
case "global":
|
||||
// TODO: verify user is admin
|
||||
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) {
|
||||
database.DB.ExecContext(c.Request.Context(),
|
||||
`UPDATE kb_documents SET storage_key = $1 WHERE id = $2`,
|
||||
storageKey, docID)
|
||||
}
|
||||
|
||||
// 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})
|
||||
}
|
||||
|
||||
// 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.
|
||||
func BuildKBHint(ctx context.Context, stores store.Stores, channelID, userID string) string {
|
||||
teamIDs, _ := stores.Teams.GetUserTeamIDs(ctx, userID)
|
||||
|
||||
kbIDs, err := stores.KnowledgeBases.GetActiveKBIDs(ctx, channelID, userID, teamIDs)
|
||||
if err != nil || len(kbIDs) == 0 {
|
||||
// Also check personal KBs — always available
|
||||
personalKBs, err := stores.KnowledgeBases.ListPersonal(ctx, userID)
|
||||
if err != nil || len(personalKBs) == 0 {
|
||||
return ""
|
||||
}
|
||||
// Only include personal KBs that have chunks
|
||||
hasContent := false
|
||||
for _, kb := range personalKBs {
|
||||
if kb.ChunkCount > 0 {
|
||||
hasContent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasContent {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Collect KB details for the hint
|
||||
type kbInfo struct {
|
||||
Name string
|
||||
DocCount int
|
||||
}
|
||||
var kbs []kbInfo
|
||||
|
||||
// Channel-linked KBs
|
||||
channelKBs, err := stores.KnowledgeBases.GetChannelKBs(ctx, channelID)
|
||||
if err == nil {
|
||||
for _, ckb := range channelKBs {
|
||||
if ckb.Enabled && ckb.DocumentCount > 0 {
|
||||
kbs = append(kbs, kbInfo{Name: ckb.KBName, DocCount: ckb.DocumentCount})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Personal KBs (not already linked to channel)
|
||||
personalKBs, err := stores.KnowledgeBases.ListPersonal(ctx, userID)
|
||||
if err == nil {
|
||||
linked := make(map[string]bool)
|
||||
for _, ckb := range channelKBs {
|
||||
linked[ckb.KBID] = true
|
||||
}
|
||||
for _, kb := range personalKBs {
|
||||
if !linked[kb.ID] && kb.ChunkCount > 0 {
|
||||
kbs = append(kbs, kbInfo{Name: kb.Name, DocCount: kb.DocumentCount})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user