- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
1045 lines
29 KiB
Go
1045 lines
29 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"switchboard-core/knowledge"
|
|
"switchboard-core/models"
|
|
"switchboard-core/storage"
|
|
"switchboard-core/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,
|
|
})
|
|
}
|