mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-09-01 14:53:07 +08:00
d199628701
Quick-answer agent mode retrieves purely through vector/keyword chunk search and ships with no `allowed_tools`, so the existing capability filter (which only reads from `allowed_tools`) let wiki-only KBs through in every entry point. End result: users could @-mention, select, and receive suggested questions from wiki-only KBs in quick-answer mode, but the underlying retrieval always returned empty. Treat "RAG-only" as an implicit property of `agent_mode = quick-answer` and union it with the tool-derived filter. The same predicate is now used everywhere the user can pick or be steered toward a KB: Backend - `tools.DeriveKBFilterForAgent` / `KBSatisfiesAgentRequirements` layer the implicit quick-answer requirement on top of tool derivation. - `ListKnowledgeBases`, `SearchKnowledge` (shared-agent `@file`), `resolveKnowledgeBasesFromAgent` (chat runtime), `/search` IM command, and `GetSuggestedQuestions` now all use the agent-mode-aware variant. - `GetSuggestedQuestions` also skips the wiki-page fallback for quick-answer agents to cover the `selected` / explicit-kb-ids paths where a wiki-only KB could still slip through. Frontend - `deriveKbFilterForAgent` / `kbSatisfiesAgentRequirements` mirror the Go helpers. - `@` mention dropdown (`Input-field.vue`) uses the new helper. - Agent editor's "specified KB" picker (`AgentEditorModal.vue`) grays out wiki-only KBs for quick-answer agents with a tooltip, and the pre-save warning fires for quick-answer mode too. - i18n: add `agentEditor.agentType.kbMismatch.quickAnswer` across all four locales.
893 lines
31 KiB
Go
893 lines
31 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
stderrors "errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Tencent/WeKnora/internal/agent/tools"
|
|
"github.com/Tencent/WeKnora/internal/application/repository"
|
|
"github.com/Tencent/WeKnora/internal/application/service"
|
|
"github.com/Tencent/WeKnora/internal/errors"
|
|
apperrors "github.com/Tencent/WeKnora/internal/errors"
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|
"github.com/Tencent/WeKnora/internal/utils"
|
|
secutils "github.com/Tencent/WeKnora/internal/utils"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/hibiken/asynq"
|
|
)
|
|
|
|
// KnowledgeBaseHandler defines the HTTP handler for knowledge base operations
|
|
type KnowledgeBaseHandler struct {
|
|
service interfaces.KnowledgeBaseService
|
|
knowledgeService interfaces.KnowledgeService
|
|
kbShareService interfaces.KBShareService
|
|
agentShareService interfaces.AgentShareService
|
|
asynqClient interfaces.TaskEnqueuer
|
|
}
|
|
|
|
// NewKnowledgeBaseHandler creates a new knowledge base handler instance
|
|
func NewKnowledgeBaseHandler(
|
|
service interfaces.KnowledgeBaseService,
|
|
knowledgeService interfaces.KnowledgeService,
|
|
kbShareService interfaces.KBShareService,
|
|
agentShareService interfaces.AgentShareService,
|
|
asynqClient interfaces.TaskEnqueuer,
|
|
) *KnowledgeBaseHandler {
|
|
return &KnowledgeBaseHandler{
|
|
service: service,
|
|
knowledgeService: knowledgeService,
|
|
kbShareService: kbShareService,
|
|
agentShareService: agentShareService,
|
|
asynqClient: asynqClient,
|
|
}
|
|
}
|
|
|
|
// HybridSearch godoc
|
|
// @Summary 混合搜索
|
|
// @Description 在知识库中执行向量和关键词混合搜索
|
|
// @Tags 知识库
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "知识库ID"
|
|
// @Param request body types.SearchParams true "搜索参数"
|
|
// @Success 200 {object} map[string]interface{} "搜索结果"
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|
// @Security Bearer
|
|
// @Security ApiKeyAuth
|
|
// @Router /knowledge-bases/{id}/hybrid-search [get]
|
|
func (h *KnowledgeBaseHandler) HybridSearch(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
logger.Info(ctx, "Start hybrid search")
|
|
|
|
// Validate and check permission for knowledge base access
|
|
_, id, effectiveTenantID, _, err := h.validateAndGetKnowledgeBase(c)
|
|
if err != nil {
|
|
c.Error(err)
|
|
return
|
|
}
|
|
|
|
// Parse request body
|
|
var req types.SearchParams
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
logger.Error(ctx, "Failed to parse request parameters", err)
|
|
c.Error(apperrors.NewBadRequestError("Invalid request parameters").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "Executing hybrid search, knowledge base ID: %s, query: %s, effectiveTenantID: %d",
|
|
secutils.SanitizeForLog(id), secutils.SanitizeForLog(req.QueryText), effectiveTenantID)
|
|
|
|
// Execute hybrid search with default search parameters
|
|
// Note: For shared KBs, the service uses effectiveTenantID internally via context
|
|
results, err := h.service.HybridSearch(ctx, id, req)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|
c.Error(apperrors.NewInternalServerError(err.Error()))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "Hybrid search completed, knowledge base ID: %s, result count: %d",
|
|
secutils.SanitizeForLog(id), len(results))
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": results,
|
|
})
|
|
}
|
|
|
|
// CreateKnowledgeBase godoc
|
|
// @Summary 创建知识库
|
|
// @Description 创建新的知识库
|
|
// @Tags 知识库
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body types.KnowledgeBase true "知识库信息"
|
|
// @Success 201 {object} map[string]interface{} "创建的知识库"
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|
// @Security Bearer
|
|
// @Security ApiKeyAuth
|
|
// @Router /knowledge-bases [post]
|
|
func (h *KnowledgeBaseHandler) CreateKnowledgeBase(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
logger.Info(ctx, "Start creating knowledge base")
|
|
|
|
// Parse request body
|
|
var req types.KnowledgeBase
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
logger.Error(ctx, "Failed to parse request parameters", err)
|
|
c.Error(apperrors.NewBadRequestError("Invalid request parameters").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
if err := validateExtractConfig(req.ExtractConfig); err != nil {
|
|
logger.Error(ctx, "Invalid extract configuration", err)
|
|
c.Error(err)
|
|
return
|
|
}
|
|
provider := strings.ToLower(strings.TrimSpace(req.GetStorageProvider()))
|
|
if provider != "" && !isStorageProviderAllowed(provider) {
|
|
c.Error(apperrors.NewBadRequestError("Storage provider is not allowed by STORAGE_ALLOW_LIST"))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "Creating knowledge base, name: %s", secutils.SanitizeForLog(req.Name))
|
|
// Create knowledge base using the service
|
|
kb, err := h.service.CreateKnowledgeBase(ctx, &req)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|
c.Error(apperrors.NewInternalServerError(err.Error()))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "Knowledge base created successfully, ID: %s, name: %s",
|
|
secutils.SanitizeForLog(kb.ID), secutils.SanitizeForLog(kb.Name))
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"success": true,
|
|
"data": kb,
|
|
})
|
|
}
|
|
|
|
// validateAndGetKnowledgeBase validates request parameters and retrieves the knowledge base
|
|
// Returns the knowledge base, knowledge base ID, effective tenant ID for embedding, permission level, and any errors encountered
|
|
// For owned KBs, effectiveTenantID is the caller's tenant ID
|
|
// For shared KBs, effectiveTenantID is the source tenant ID (owner's tenant)
|
|
func (h *KnowledgeBaseHandler) validateAndGetKnowledgeBase(c *gin.Context) (*types.KnowledgeBase, string, uint64, types.OrgMemberRole, error) {
|
|
ctx := c.Request.Context()
|
|
|
|
// Get tenant ID from context
|
|
tenantID, exists := c.Get(types.TenantIDContextKey.String())
|
|
if !exists {
|
|
logger.Error(ctx, "Failed to get tenant ID")
|
|
return nil, "", 0, "", apperrors.NewUnauthorizedError("Unauthorized")
|
|
}
|
|
|
|
// Get user ID from context (needed for shared KB permission check)
|
|
userID, userExists := c.Get(types.UserIDContextKey.String())
|
|
|
|
// Get knowledge base ID from URL parameter
|
|
id := secutils.SanitizeForLog(c.Param("id"))
|
|
if id == "" {
|
|
logger.Error(ctx, "Knowledge base ID is empty")
|
|
return nil, "", 0, "", apperrors.NewBadRequestError("Knowledge base ID cannot be empty")
|
|
}
|
|
|
|
// Verify tenant has permission to access this knowledge base
|
|
kb, err := h.service.GetKnowledgeBaseByID(ctx, id)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|
return nil, id, 0, "", apperrors.NewInternalServerError(err.Error())
|
|
}
|
|
|
|
// Check 1: Verify tenant ownership (owner has full access)
|
|
if kb.TenantID == tenantID.(uint64) {
|
|
return kb, id, tenantID.(uint64), types.OrgRoleAdmin, nil
|
|
}
|
|
|
|
// Check 2: If not owner, check organization shared access
|
|
if userExists && h.kbShareService != nil {
|
|
// Check if user has shared access through organization
|
|
permission, isShared, permErr := h.kbShareService.CheckUserKBPermission(ctx, id, userID.(string))
|
|
if permErr == nil && isShared {
|
|
// User has shared access, get the source tenant ID for embedding queries
|
|
sourceTenantID, srcErr := h.kbShareService.GetKBSourceTenant(ctx, id)
|
|
if srcErr == nil {
|
|
logger.Infof(ctx, "User %s accessing shared KB %s with permission %s, source tenant: %d",
|
|
userID.(string), id, permission, sourceTenantID)
|
|
return kb, id, sourceTenantID, permission, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check 3: Shared agent — allow if request has agent_id (and agent can access this KB) OR user has any shared agent that can access this KB (e.g. opened from "通过智能体可见" list without agent_id)
|
|
if userExists && h.agentShareService != nil {
|
|
currentTenantID := tenantID.(uint64)
|
|
agentID := c.Query("agent_id")
|
|
if agentID != "" {
|
|
agent, err := h.agentShareService.GetSharedAgentForUser(ctx, userID.(string), currentTenantID, agentID)
|
|
if err == nil && agent != nil {
|
|
if kb.TenantID != agent.TenantID {
|
|
logger.Warnf(ctx, "Shared agent tenant mismatch, KB %s tenant: %d, agent tenant: %d", id, kb.TenantID, agent.TenantID)
|
|
} else {
|
|
mode := agent.Config.KBSelectionMode
|
|
if mode == "none" {
|
|
// no-op, fall through
|
|
} else if mode == "all" {
|
|
logger.Infof(ctx, "User %s accessing KB %s via shared agent %s (mode=all)", userID.(string), id, agentID)
|
|
return kb, id, kb.TenantID, types.OrgRoleViewer, nil
|
|
} else if mode == "selected" {
|
|
for _, allowedID := range agent.Config.KnowledgeBases {
|
|
if allowedID == id {
|
|
logger.Infof(ctx, "User %s accessing KB %s via shared agent %s (mode=selected)", userID.(string), id, agentID)
|
|
return kb, id, kb.TenantID, types.OrgRoleViewer, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// No agent_id in query: allow if user has any shared agent that can access this KB (e.g. from space list "通过智能体可见")
|
|
can, err := h.agentShareService.UserCanAccessKBViaSomeSharedAgent(ctx, userID.(string), currentTenantID, kb)
|
|
if err == nil && can {
|
|
logger.Infof(ctx, "User %s accessing KB %s via some shared agent (no agent_id in query)", userID.(string), id)
|
|
return kb, id, kb.TenantID, types.OrgRoleViewer, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// No permission: not owner and no shared access
|
|
logger.Warnf(
|
|
ctx,
|
|
"Tenant has no permission to access this knowledge base, knowledge base ID: %s, "+
|
|
"request tenant ID: %d, knowledge base tenant ID: %d",
|
|
id, tenantID.(uint64), kb.TenantID,
|
|
)
|
|
return nil, id, 0, "", apperrors.NewForbiddenError("No permission to operate")
|
|
}
|
|
|
|
// GetKnowledgeBase godoc
|
|
// @Summary 获取知识库详情
|
|
// @Description 根据ID获取知识库详情。当使用共享智能体时,可传 agent_id 以校验该智能体是否有权访问该知识库。
|
|
// @Tags 知识库
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "知识库ID"
|
|
// @Param agent_id query string false "共享智能体 ID(用于校验智能体是否有权访问该知识库)"
|
|
// @Success 200 {object} map[string]interface{} "知识库详情"
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|
// @Failure 404 {object} errors.AppError "知识库不存在"
|
|
// @Security Bearer
|
|
// @Security ApiKeyAuth
|
|
// @Router /knowledge-bases/{id} [get]
|
|
func (h *KnowledgeBaseHandler) GetKnowledgeBase(c *gin.Context) {
|
|
// Validate and get the knowledge base
|
|
kb, _, _, permission, err := h.validateAndGetKnowledgeBase(c)
|
|
if err != nil {
|
|
c.Error(err)
|
|
return
|
|
}
|
|
// Fill counts (knowledge_count, chunk_count, is_processing) so hover/detail shows correct numbers
|
|
if fillErr := h.service.FillKnowledgeBaseCounts(c.Request.Context(), kb); fillErr != nil {
|
|
logger.Warnf(c.Request.Context(), "Failed to fill KB counts for %s: %v", kb.ID, fillErr)
|
|
}
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
data := interface{}(kb)
|
|
if kb.TenantID != tenantID && permission != "" {
|
|
// Include my_permission in data so frontend can show role (e.g. "只读") instead of "--" for agent-visible KBs
|
|
var dataMap map[string]interface{}
|
|
b, _ := json.Marshal(kb)
|
|
_ = json.Unmarshal(b, &dataMap)
|
|
if dataMap != nil {
|
|
dataMap["my_permission"] = permission
|
|
data = dataMap
|
|
}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
|
|
}
|
|
|
|
// ListKnowledgeBases godoc
|
|
// @Summary 获取知识库列表
|
|
// @Description 获取当前租户的所有知识库;或当传入 agent_id(共享智能体)时,校验权限后返回该智能体配置的知识库范围(用于 @ 提及)
|
|
// @Tags 知识库
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param agent_id query string false "共享智能体 ID(传入时返回该智能体可用的知识库)"
|
|
// @Success 200 {object} map[string]interface{} "知识库列表"
|
|
// @Failure 500 {object} errors.AppError "服务器错误"
|
|
// @Security Bearer
|
|
// @Security ApiKeyAuth
|
|
// @Router /knowledge-bases [get]
|
|
func (h *KnowledgeBaseHandler) ListKnowledgeBases(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
agentID := c.Query("agent_id")
|
|
if agentID != "" {
|
|
userIDVal, ok := c.Get(types.UserIDContextKey.String())
|
|
if !ok {
|
|
c.Error(apperrors.NewUnauthorizedError("user ID not found"))
|
|
return
|
|
}
|
|
userID, _ := userIDVal.(string)
|
|
currentTenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
if currentTenantID == 0 {
|
|
c.Error(apperrors.NewUnauthorizedError("tenant ID not found"))
|
|
return
|
|
}
|
|
agent, err := h.agentShareService.GetSharedAgentForUser(ctx, userID, currentTenantID, agentID)
|
|
if err != nil {
|
|
if stderrors.Is(err, service.ErrAgentShareNotFound) || stderrors.Is(err, service.ErrAgentSharePermission) || stderrors.Is(err, service.ErrAgentNotFoundForShare) {
|
|
c.Error(apperrors.NewForbiddenError("no permission for this shared agent"))
|
|
return
|
|
}
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|
c.Error(apperrors.NewInternalServerError(err.Error()))
|
|
return
|
|
}
|
|
mode := agent.Config.KBSelectionMode
|
|
if mode == "none" {
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
|
return
|
|
}
|
|
sourceTenantID := agent.TenantID
|
|
kbs, err := h.service.ListKnowledgeBasesByTenantID(ctx, sourceTenantID)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|
c.Error(apperrors.NewInternalServerError(err.Error()))
|
|
return
|
|
}
|
|
if mode == "selected" && len(agent.Config.KnowledgeBases) > 0 {
|
|
allowed := make(map[string]bool)
|
|
for _, id := range agent.Config.KnowledgeBases {
|
|
allowed[id] = true
|
|
}
|
|
filtered := make([]*types.KnowledgeBase, 0, len(kbs))
|
|
for _, kb := range kbs {
|
|
if allowed[kb.ID] {
|
|
filtered = append(filtered, kb)
|
|
}
|
|
}
|
|
kbs = filtered
|
|
}
|
|
|
|
// `all` mode: authoritative server-side capability filter so a client
|
|
// that bypassed the frontend (old tab, curl, rogue plugin) can't @ a
|
|
// KB whose capabilities don't match this agent. The filter combines
|
|
// tool-derived requirements (smart-reasoning) with the implicit
|
|
// RAG-only requirement of quick-answer mode (which has no
|
|
// `allowed_tools` but still needs vector/keyword chunks to work).
|
|
// Non-`all` modes already constrain the scope explicitly.
|
|
if mode == "all" {
|
|
filter := tools.DeriveKBFilterForAgent(agent.Config.AgentMode, agent.Config.AllowedTools)
|
|
if !filter.IsEmpty() {
|
|
before := len(kbs)
|
|
kept := make([]*types.KnowledgeBase, 0, before)
|
|
for _, kb := range kbs {
|
|
if tools.KBSatisfiesAgentRequirements(kb.Capabilities(), agent.Config.AgentMode, agent.Config.AllowedTools) {
|
|
kept = append(kept, kb)
|
|
}
|
|
}
|
|
if removed := before - len(kept); removed > 0 {
|
|
logger.Infof(ctx,
|
|
"ListKnowledgeBases(agent=%s, mode=all): capability filter removed %d of %d KBs",
|
|
agentID, removed, before)
|
|
}
|
|
kbs = kept
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": kbs,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Get all knowledge bases for this tenant
|
|
kbs, err := h.service.ListKnowledgeBases(ctx)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|
c.Error(apperrors.NewInternalServerError(err.Error()))
|
|
return
|
|
}
|
|
|
|
// Get share counts for all knowledge bases
|
|
if len(kbs) > 0 && h.kbShareService != nil {
|
|
kbIDs := make([]string, len(kbs))
|
|
for i, kb := range kbs {
|
|
kbIDs[i] = kb.ID
|
|
}
|
|
|
|
shareCounts, err := h.kbShareService.CountSharesByKnowledgeBaseIDs(ctx, kbIDs)
|
|
if err != nil {
|
|
logger.Warnf(ctx, "Failed to get share counts: %v", err)
|
|
} else {
|
|
for _, kb := range kbs {
|
|
if count, ok := shareCounts[kb.ID]; ok {
|
|
kb.ShareCount = count
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": kbs,
|
|
})
|
|
}
|
|
|
|
// TogglePinKnowledgeBase godoc
|
|
// @Summary 置顶/取消置顶知识库
|
|
// @Description 切换知识库的置顶状态
|
|
// @Tags 知识库
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "知识库ID"
|
|
// @Success 200 {object} map[string]interface{} "更新后的知识库"
|
|
// @Failure 404 {object} errors.AppError "知识库不存在"
|
|
// @Security Bearer
|
|
// @Security ApiKeyAuth
|
|
// @Router /knowledge-bases/{id}/pin [put]
|
|
func (h *KnowledgeBaseHandler) TogglePinKnowledgeBase(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
id := c.Param("id")
|
|
if id == "" {
|
|
c.Error(apperrors.NewBadRequestError("knowledge base ID is required"))
|
|
return
|
|
}
|
|
|
|
kb, err := h.service.TogglePinKnowledgeBase(ctx, id)
|
|
if err != nil {
|
|
if stderrors.Is(err, repository.ErrKnowledgeBaseNotFound) {
|
|
c.Error(apperrors.NewNotFoundError("knowledge base not found"))
|
|
return
|
|
}
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|
c.Error(apperrors.NewInternalServerError(err.Error()))
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": kb,
|
|
})
|
|
}
|
|
|
|
// UpdateKnowledgeBaseRequest defines the request body structure for updating a knowledge base
|
|
type UpdateKnowledgeBaseRequest struct {
|
|
Name string `json:"name" binding:"required"`
|
|
Description string `json:"description"`
|
|
Config *types.KnowledgeBaseConfig `json:"config"`
|
|
}
|
|
|
|
// UpdateKnowledgeBase godoc
|
|
// @Summary 更新知识库
|
|
// @Description 更新知识库的名称、描述和配置
|
|
// @Tags 知识库
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "知识库ID"
|
|
// @Param request body UpdateKnowledgeBaseRequest true "更新请求"
|
|
// @Success 200 {object} map[string]interface{} "更新后的知识库"
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|
// @Security Bearer
|
|
// @Security ApiKeyAuth
|
|
// @Router /knowledge-bases/{id} [put]
|
|
func (h *KnowledgeBaseHandler) UpdateKnowledgeBase(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
logger.Info(ctx, "Start updating knowledge base")
|
|
|
|
// Validate and get the knowledge base
|
|
_, id, _, permission, err := h.validateAndGetKnowledgeBase(c)
|
|
if err != nil {
|
|
c.Error(err)
|
|
return
|
|
}
|
|
|
|
// Only admin/editor can update knowledge base
|
|
if permission != types.OrgRoleAdmin && permission != types.OrgRoleEditor {
|
|
c.Error(apperrors.NewForbiddenError("No permission to update knowledge base"))
|
|
return
|
|
}
|
|
|
|
// Parse request body
|
|
var req UpdateKnowledgeBaseRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
logger.Error(ctx, "Failed to parse request parameters", err)
|
|
c.Error(apperrors.NewBadRequestError("Invalid request parameters").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "Updating knowledge base, ID: %s, name: %s",
|
|
secutils.SanitizeForLog(id), secutils.SanitizeForLog(req.Name))
|
|
|
|
// Update the knowledge base
|
|
kb, err := h.service.UpdateKnowledgeBase(ctx, id, req.Name, req.Description, req.Config)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|
c.Error(apperrors.NewInternalServerError(err.Error()))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "Knowledge base updated successfully, ID: %s",
|
|
secutils.SanitizeForLog(id))
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": kb,
|
|
})
|
|
}
|
|
|
|
// DeleteKnowledgeBase godoc
|
|
// @Summary 删除知识库
|
|
// @Description 删除指定的知识库及其所有内容
|
|
// @Tags 知识库
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "知识库ID"
|
|
// @Success 200 {object} map[string]interface{} "删除成功"
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|
// @Security Bearer
|
|
// @Security ApiKeyAuth
|
|
// @Router /knowledge-bases/{id} [delete]
|
|
func (h *KnowledgeBaseHandler) DeleteKnowledgeBase(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
logger.Info(ctx, "Start deleting knowledge base")
|
|
|
|
// Validate and get the knowledge base
|
|
kb, id, _, permission, err := h.validateAndGetKnowledgeBase(c)
|
|
if err != nil {
|
|
c.Error(err)
|
|
return
|
|
}
|
|
|
|
// Only owner (admin with matching tenant) can delete knowledge base
|
|
tenantID, _ := c.Get(types.TenantIDContextKey.String())
|
|
if kb.TenantID != tenantID.(uint64) || permission != types.OrgRoleAdmin {
|
|
c.Error(apperrors.NewForbiddenError("Only knowledge base owner can delete"))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "Deleting knowledge base, ID: %s, name: %s",
|
|
secutils.SanitizeForLog(id), secutils.SanitizeForLog(kb.Name))
|
|
|
|
// Delete the knowledge base
|
|
if err := h.service.DeleteKnowledgeBase(ctx, id); err != nil {
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|
c.Error(apperrors.NewInternalServerError(err.Error()))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "Knowledge base deleted successfully, ID: %s",
|
|
secutils.SanitizeForLog(id))
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "Knowledge base deleted successfully",
|
|
})
|
|
}
|
|
|
|
type CopyKnowledgeBaseRequest struct {
|
|
TaskID string `json:"task_id"`
|
|
SourceID string `json:"source_id" binding:"required"`
|
|
TargetID string `json:"target_id"`
|
|
}
|
|
|
|
// CopyKnowledgeBaseResponse defines the response for copy knowledge base
|
|
type CopyKnowledgeBaseResponse struct {
|
|
TaskID string `json:"task_id"`
|
|
SourceID string `json:"source_id"`
|
|
TargetID string `json:"target_id"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// CopyKnowledgeBase godoc
|
|
// @Summary 复制知识库
|
|
// @Description 将一个知识库的内容复制到另一个知识库(异步任务)
|
|
// @Tags 知识库
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body CopyKnowledgeBaseRequest true "复制请求"
|
|
// @Success 200 {object} map[string]interface{} "任务ID"
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|
// @Security Bearer
|
|
// @Security ApiKeyAuth
|
|
// @Router /knowledge-bases/copy [post]
|
|
func (h *KnowledgeBaseHandler) CopyKnowledgeBase(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
var req CopyKnowledgeBaseRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
logger.Error(ctx, "Failed to parse request parameters", err)
|
|
c.Error(apperrors.NewBadRequestError("Invalid request parameters").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
|
|
// Get tenant ID from context
|
|
tenantID, exists := c.Get(types.TenantIDContextKey.String())
|
|
if !exists {
|
|
logger.Error(ctx, "Failed to get tenant ID")
|
|
c.Error(apperrors.NewUnauthorizedError("Unauthorized"))
|
|
return
|
|
}
|
|
|
|
// Validate source knowledge base exists and belongs to caller's tenant (prevent cross-tenant clone)
|
|
sourceKB, err := h.service.GetKnowledgeBaseByID(ctx, req.SourceID)
|
|
if err != nil {
|
|
if stderrors.Is(err, repository.ErrKnowledgeBaseNotFound) {
|
|
c.Error(errors.NewNotFoundError("Source knowledge base not found"))
|
|
return
|
|
}
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|
return
|
|
}
|
|
if sourceKB.TenantID != tenantID.(uint64) {
|
|
logger.Warnf(ctx,
|
|
"Copy rejected: source knowledge base belongs to another tenant, source_id: %s, caller_tenant: %d, kb_tenant: %d",
|
|
secutils.SanitizeForLog(req.SourceID), tenantID.(uint64), sourceKB.TenantID)
|
|
c.Error(errors.NewForbiddenError("No permission to copy this knowledge base"))
|
|
return
|
|
}
|
|
|
|
// If target_id provided, validate target belongs to caller's tenant
|
|
if req.TargetID != "" {
|
|
targetKB, err := h.service.GetKnowledgeBaseByID(ctx, req.TargetID)
|
|
if err != nil {
|
|
if stderrors.Is(err, repository.ErrKnowledgeBaseNotFound) {
|
|
c.Error(errors.NewNotFoundError("Target knowledge base not found"))
|
|
return
|
|
}
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|
return
|
|
}
|
|
if targetKB.TenantID != tenantID.(uint64) {
|
|
logger.Warnf(ctx, "Copy rejected: target knowledge base belongs to another tenant, target_id: %s",
|
|
secutils.SanitizeForLog(req.TargetID))
|
|
c.Error(errors.NewForbiddenError("No permission to copy to this knowledge base"))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Generate task ID if not provided
|
|
taskID := req.TaskID
|
|
if taskID == "" {
|
|
taskID = utils.GenerateTaskID("kb_clone", tenantID.(uint64), req.SourceID)
|
|
}
|
|
|
|
// Create KB clone payload
|
|
payload := types.KBClonePayload{
|
|
TenantID: tenantID.(uint64),
|
|
TaskID: taskID,
|
|
SourceID: req.SourceID,
|
|
TargetID: req.TargetID,
|
|
}
|
|
langfuse.InjectTracing(ctx, &payload)
|
|
|
|
payloadBytes, err := json.Marshal(payload)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to marshal KB clone payload: %v", err)
|
|
c.Error(apperrors.NewInternalServerError("Failed to create task"))
|
|
return
|
|
}
|
|
|
|
// Enqueue KB clone task to Asynq
|
|
task := asynq.NewTask(types.TypeKBClone, payloadBytes,
|
|
asynq.TaskID(taskID), asynq.Queue("default"), asynq.MaxRetry(3))
|
|
info, err := h.asynqClient.Enqueue(task)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to enqueue KB clone task: %v", err)
|
|
c.Error(apperrors.NewInternalServerError("Failed to enqueue task"))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "KB clone task enqueued: %s, asynq task ID: %s, source: %s, target: %s",
|
|
taskID, info.ID, secutils.SanitizeForLog(req.SourceID), secutils.SanitizeForLog(req.TargetID))
|
|
|
|
// Save initial progress to Redis so frontend can query immediately
|
|
initialProgress := &types.KBCloneProgress{
|
|
TaskID: taskID,
|
|
SourceID: req.SourceID,
|
|
TargetID: req.TargetID,
|
|
Status: types.KBCloneStatusPending,
|
|
Progress: 0,
|
|
Message: "Task queued, waiting to start...",
|
|
CreatedAt: time.Now().Unix(),
|
|
UpdatedAt: time.Now().Unix(),
|
|
}
|
|
if err := h.knowledgeService.SaveKBCloneProgress(ctx, initialProgress); err != nil {
|
|
logger.Warnf(ctx, "Failed to save initial KB clone progress: %v", err)
|
|
// Don't fail the request, task is already enqueued
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": CopyKnowledgeBaseResponse{
|
|
TaskID: taskID,
|
|
SourceID: req.SourceID,
|
|
TargetID: req.TargetID,
|
|
Message: "Knowledge base copy task started",
|
|
},
|
|
})
|
|
}
|
|
|
|
// GetKBCloneProgress godoc
|
|
// @Summary 获取知识库复制进度
|
|
// @Description 获取知识库复制任务的进度
|
|
// @Tags 知识库
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param task_id path string true "任务ID"
|
|
// @Success 200 {object} map[string]interface{} "进度信息"
|
|
// @Failure 404 {object} errors.AppError "任务不存在"
|
|
// @Security Bearer
|
|
// @Security ApiKeyAuth
|
|
// @Router /knowledge-bases/copy/progress/{task_id} [get]
|
|
func (h *KnowledgeBaseHandler) GetKBCloneProgress(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
taskID := c.Param("task_id")
|
|
if taskID == "" {
|
|
logger.Error(ctx, "Task ID is empty")
|
|
c.Error(apperrors.NewBadRequestError("Task ID cannot be empty"))
|
|
return
|
|
}
|
|
|
|
progress, err := h.knowledgeService.GetKBCloneProgress(ctx, taskID)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|
c.Error(err)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": progress,
|
|
})
|
|
}
|
|
|
|
// validateExtractConfig validates the graph configuration parameters
|
|
func validateExtractConfig(config *types.ExtractConfig) error {
|
|
if config == nil {
|
|
return nil
|
|
}
|
|
if !config.Enabled {
|
|
*config = types.ExtractConfig{Enabled: false}
|
|
return nil
|
|
}
|
|
// Validate text field
|
|
if config.Text == "" {
|
|
return apperrors.NewBadRequestError("text cannot be empty")
|
|
}
|
|
|
|
// Validate tags field
|
|
if len(config.Tags) == 0 {
|
|
return apperrors.NewBadRequestError("tags cannot be empty")
|
|
}
|
|
for i, tag := range config.Tags {
|
|
if tag == "" {
|
|
return apperrors.NewBadRequestError("tag cannot be empty at index " + strconv.Itoa(i))
|
|
}
|
|
}
|
|
|
|
// Validate nodes
|
|
if len(config.Nodes) == 0 {
|
|
return apperrors.NewBadRequestError("nodes cannot be empty")
|
|
}
|
|
nodeNames := make(map[string]bool)
|
|
for i, node := range config.Nodes {
|
|
if node.Name == "" {
|
|
return apperrors.NewBadRequestError("node name cannot be empty at index " + strconv.Itoa(i))
|
|
}
|
|
// Check for duplicate node names
|
|
if nodeNames[node.Name] {
|
|
return apperrors.NewBadRequestError("duplicate node name: " + node.Name)
|
|
}
|
|
nodeNames[node.Name] = true
|
|
}
|
|
|
|
if len(config.Relations) == 0 {
|
|
return apperrors.NewBadRequestError("relations cannot be empty")
|
|
}
|
|
// Validate relations
|
|
for i, relation := range config.Relations {
|
|
if relation.Node1 == "" {
|
|
return apperrors.NewBadRequestError("relation node1 cannot be empty at index " + strconv.Itoa(i))
|
|
}
|
|
if relation.Node2 == "" {
|
|
return apperrors.NewBadRequestError("relation node2 cannot be empty at index " + strconv.Itoa(i))
|
|
}
|
|
if relation.Type == "" {
|
|
return apperrors.NewBadRequestError("relation type cannot be empty at index " + strconv.Itoa(i))
|
|
}
|
|
// Check if referenced nodes exist
|
|
if !nodeNames[relation.Node1] {
|
|
return apperrors.NewBadRequestError("relation references non-existent node1: " + relation.Node1)
|
|
}
|
|
if !nodeNames[relation.Node2] {
|
|
return apperrors.NewBadRequestError("relation references non-existent node2: " + relation.Node2)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ListMoveTargets returns knowledge bases eligible as move targets for the given source KB.
|
|
// Filters: same Type, same EmbeddingModelID, different ID, not temporary.
|
|
//
|
|
// ListMoveTargets godoc
|
|
// @Summary 获取可移动目标知识库列表
|
|
// @Description 返回与源知识库 Type 一致、EmbeddingModelID 一致、非临时且不是自身的目标知识库列表
|
|
// @Tags 知识库
|
|
// @Produce json
|
|
// @Param id path string true "源知识库 ID"
|
|
// @Success 200 {object} map[string]interface{} "可移动目标列表"
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|
// @Failure 404 {object} errors.AppError "知识库不存在"
|
|
// @Security Bearer
|
|
// @Security ApiKeyAuth
|
|
// @Router /knowledge-bases/{id}/move-targets [get]
|
|
func (h *KnowledgeBaseHandler) ListMoveTargets(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
sourceKBID := c.Param("id")
|
|
if sourceKBID == "" {
|
|
c.Error(apperrors.NewBadRequestError("Knowledge base ID is required"))
|
|
return
|
|
}
|
|
|
|
tenantID, exists := c.Get(types.TenantIDContextKey.String())
|
|
if !exists {
|
|
c.Error(apperrors.NewUnauthorizedError("Unauthorized"))
|
|
return
|
|
}
|
|
|
|
// Get source knowledge base
|
|
sourceKB, err := h.service.GetKnowledgeBaseByID(ctx, sourceKBID)
|
|
if err != nil {
|
|
if stderrors.Is(err, repository.ErrKnowledgeBaseNotFound) {
|
|
c.Error(errors.NewNotFoundError("Source knowledge base not found"))
|
|
return
|
|
}
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|
return
|
|
}
|
|
if sourceKB.TenantID != tenantID.(uint64) {
|
|
c.Error(errors.NewForbiddenError("No permission to access this knowledge base"))
|
|
return
|
|
}
|
|
|
|
// Get all knowledge bases
|
|
allKBs, err := h.service.ListKnowledgeBases(ctx)
|
|
if err != nil {
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|
return
|
|
}
|
|
|
|
// Filter eligible targets
|
|
targets := make([]*types.KnowledgeBase, 0)
|
|
for _, kb := range allKBs {
|
|
if kb.ID == sourceKBID {
|
|
continue
|
|
}
|
|
if kb.IsTemporary {
|
|
continue
|
|
}
|
|
if kb.Type != sourceKB.Type {
|
|
continue
|
|
}
|
|
if kb.EmbeddingModelID != sourceKB.EmbeddingModelID {
|
|
continue
|
|
}
|
|
targets = append(targets, kb)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": targets,
|
|
})
|
|
}
|