2902 lines
103 KiB
Go
2902 lines
103 KiB
Go
package handler
|
||||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"encoding/json"
|
|||
|
|
"fmt"
|
|||
|
|
"io"
|
|||
|
|
"mime"
|
|||
|
|
"net/http"
|
|||
|
|
"strconv"
|
|||
|
|
"strings"
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
goerrors "errors"
|
|||
|
|
|
|||
|
|
"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/config"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/errors"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/middleware"
|
|||
|
|
"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"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// KnowledgeHandler processes HTTP requests related to knowledge resources
|
|||
|
|
type KnowledgeHandler struct {
|
|||
|
|
cfg *config.Config
|
|||
|
|
kgService interfaces.KnowledgeService
|
|||
|
|
kbService interfaces.KnowledgeBaseService
|
|||
|
|
kbShareService interfaces.KBShareService
|
|||
|
|
agentShareService interfaces.AgentShareService
|
|||
|
|
asynqClient interfaces.TaskEnqueuer
|
|||
|
|
spanRepo repository.KnowledgeSpanRepository
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewKnowledgeHandler creates a new knowledge handler instance
|
|||
|
|
func NewKnowledgeHandler(
|
|||
|
|
cfg *config.Config,
|
|||
|
|
kgService interfaces.KnowledgeService,
|
|||
|
|
kbService interfaces.KnowledgeBaseService,
|
|||
|
|
kbShareService interfaces.KBShareService,
|
|||
|
|
agentShareService interfaces.AgentShareService,
|
|||
|
|
asynqClient interfaces.TaskEnqueuer,
|
|||
|
|
spanRepo repository.KnowledgeSpanRepository,
|
|||
|
|
) *KnowledgeHandler {
|
|||
|
|
return &KnowledgeHandler{
|
|||
|
|
cfg: cfg,
|
|||
|
|
kgService: kgService,
|
|||
|
|
kbService: kbService,
|
|||
|
|
kbShareService: kbShareService,
|
|||
|
|
agentShareService: agentShareService,
|
|||
|
|
asynqClient: asynqClient,
|
|||
|
|
spanRepo: spanRepo,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// requireKBOwnershipOrAdmin enforces the same "KB creator OR Admin+" matrix
|
|||
|
|
// used by OwnedKBOrAdmin for routes whose KB id comes from the request body.
|
|||
|
|
func (h *KnowledgeHandler) requireKBOwnershipOrAdmin(c *gin.Context, kbID string) error {
|
|||
|
|
creator, err := resolveKBCreatorByKBID(c, h.kbService, kbID)
|
|||
|
|
evalErr := middleware.EvaluateOwnershipOrRole(
|
|||
|
|
c.Request.Context(),
|
|||
|
|
h.cfg,
|
|||
|
|
types.TenantRoleAdmin,
|
|||
|
|
creator,
|
|||
|
|
err,
|
|||
|
|
)
|
|||
|
|
if evalErr == nil {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
if goerrors.Is(evalErr, middleware.ErrResourceNotFound) {
|
|||
|
|
return errors.NewNotFoundError("knowledge base not found")
|
|||
|
|
}
|
|||
|
|
if goerrors.Is(evalErr, middleware.ErrOwnershipForbidden) {
|
|||
|
|
return errors.NewForbiddenError("No permission to operate on this knowledge base")
|
|||
|
|
}
|
|||
|
|
logger.ErrorWithFields(c.Request.Context(), evalErr, map[string]interface{}{
|
|||
|
|
"kb_id": secutils.SanitizeForLog(kbID),
|
|||
|
|
})
|
|||
|
|
return errors.NewInternalServerError("cannot verify knowledge base ownership")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// validateKnowledgeBaseAccess validates access permissions to a knowledge base
|
|||
|
|
// using the ":id" URL path parameter. It delegates to validateKnowledgeBaseAccessWithKBID.
|
|||
|
|
func (h *KnowledgeHandler) validateKnowledgeBaseAccess(c *gin.Context) (*types.KnowledgeBase, string, uint64, types.OrgMemberRole, error) {
|
|||
|
|
kbID := secutils.SanitizeForLog(c.Param("id"))
|
|||
|
|
return h.validateKnowledgeBaseAccessWithKBID(c, kbID)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// validateKnowledgeBaseAccessWithKBID validates access to the given knowledge base ID (e.g. from query or body).
|
|||
|
|
// Enforces per-API-key KB scope before tenant/share/agent resolution.
|
|||
|
|
// Returns the knowledge base, kbID, effective tenant ID, permission, and error.
|
|||
|
|
func (h *KnowledgeHandler) validateKnowledgeBaseAccessWithKBID(c *gin.Context, kbID string) (*types.KnowledgeBase, string, uint64, types.OrgMemberRole, error) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|||
|
|
if tenantID == 0 {
|
|||
|
|
logger.Error(ctx, "Failed to get tenant ID")
|
|||
|
|
return nil, "", 0, "", errors.NewUnauthorizedError("Unauthorized")
|
|||
|
|
}
|
|||
|
|
userID, userExists := c.Get(types.UserIDContextKey.String())
|
|||
|
|
callerTenantRole := types.TenantRoleFromContext(ctx)
|
|||
|
|
kbID = secutils.SanitizeForLog(kbID)
|
|||
|
|
if kbID == "" {
|
|||
|
|
return nil, "", 0, "", errors.NewBadRequestError("Knowledge base ID cannot be empty")
|
|||
|
|
}
|
|||
|
|
if err := requireTenantAPIKeyKnowledgeBase(ctx, kbID); err != nil {
|
|||
|
|
return nil, kbID, 0, "", err
|
|||
|
|
}
|
|||
|
|
kb, err := h.kbService.GetKnowledgeBaseByID(ctx, kbID)
|
|||
|
|
if err != nil {
|
|||
|
|
// Same not-found-vs-real-error split as knowledgebase.go's
|
|||
|
|
// validateAndGetKnowledgeBase: ErrKnowledgeBaseNotFound is the
|
|||
|
|
// expected outcome for a probed/stale kb id and must surface as
|
|||
|
|
// 404, not the generic 500 the original code produced.
|
|||
|
|
if goerrors.Is(err, repository.ErrKnowledgeBaseNotFound) {
|
|||
|
|
return nil, kbID, 0, "", errors.NewNotFoundError("knowledge base not found")
|
|||
|
|
}
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
return nil, kbID, 0, "", errors.NewInternalServerError(err.Error())
|
|||
|
|
}
|
|||
|
|
if kb.TenantID == tenantID {
|
|||
|
|
return kb, kbID, tenantID, types.OrgRoleAdmin, nil
|
|||
|
|
}
|
|||
|
|
if h.kbShareService != nil {
|
|||
|
|
permission, isShared, permErr := h.kbShareService.CheckTenantKBPermission(ctx, kbID, tenantID, callerTenantRole)
|
|||
|
|
if permErr == nil && isShared {
|
|||
|
|
sourceTenantID, srcErr := h.kbShareService.GetKBSourceTenant(ctx, kbID)
|
|||
|
|
if srcErr == nil {
|
|||
|
|
logger.Infof(ctx, "Tenant %d accessing shared KB %s with permission %s, source tenant: %d",
|
|||
|
|
tenantID, kbID, permission, sourceTenantID)
|
|||
|
|
return kb, kbID, sourceTenantID, permission, nil
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if h.agentShareService != nil {
|
|||
|
|
can, err := h.agentShareService.TenantCanAccessKBViaSomeSharedAgent(ctx, tenantID, callerTenantRole, kb)
|
|||
|
|
if err == nil && can {
|
|||
|
|
logger.Infof(ctx, "Tenant %d accessing KB %s via some shared agent", tenantID, kbID)
|
|||
|
|
return kb, kbID, kb.TenantID, types.OrgRoleViewer, nil
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
_ = userID
|
|||
|
|
_ = userExists
|
|||
|
|
logger.Warnf(ctx, "Permission denied to access KB %s, tenant ID: %d, KB tenant: %d", kbID, tenantID, kb.TenantID)
|
|||
|
|
return nil, kbID, 0, "", errors.NewForbiddenError("Permission denied to access this knowledge base")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// resolveKnowledgeAndValidateKBAccess resolves knowledge by ID and validates KB access (owner or shared with required permission).
|
|||
|
|
// Returns the knowledge, context with effectiveTenantID set for downstream service calls, and error.
|
|||
|
|
func (h *KnowledgeHandler) resolveKnowledgeAndValidateKBAccess(c *gin.Context, knowledgeID string, requiredPermission types.OrgMemberRole) (*types.Knowledge, context.Context, error) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|||
|
|
if tenantID == 0 {
|
|||
|
|
return nil, ctx, errors.NewUnauthorizedError("Unauthorized")
|
|||
|
|
}
|
|||
|
|
userID, userExists := c.Get(types.UserIDContextKey.String())
|
|||
|
|
callerTenantRole := types.TenantRoleFromContext(ctx)
|
|||
|
|
|
|||
|
|
knowledge, err := h.kgService.GetKnowledgeByIDOnly(ctx, knowledgeID)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, ctx, errors.NewNotFoundError("Knowledge not found")
|
|||
|
|
}
|
|||
|
|
if err := requireTenantAPIKeyKnowledgeBase(ctx, knowledge.KnowledgeBaseID); err != nil {
|
|||
|
|
return nil, ctx, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Owner: knowledge belongs to caller's tenant
|
|||
|
|
if knowledge.TenantID == tenantID {
|
|||
|
|
return knowledge, context.WithValue(ctx, types.TenantIDContextKey, tenantID), nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Shared KB: check organization permission
|
|||
|
|
if h.kbShareService != nil {
|
|||
|
|
permission, isShared, permErr := h.kbShareService.CheckTenantKBPermission(ctx, knowledge.KnowledgeBaseID, tenantID, callerTenantRole)
|
|||
|
|
if permErr == nil && isShared && permission.HasPermission(requiredPermission) {
|
|||
|
|
effectiveTenantID := knowledge.TenantID
|
|||
|
|
return knowledge, context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID), nil
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// Shared agent: request passes agent_id, or user has any shared agent that can access this KB
|
|||
|
|
if h.agentShareService != nil && requiredPermission == types.OrgRoleViewer {
|
|||
|
|
agentID := c.Query("agent_id")
|
|||
|
|
if agentID != "" {
|
|||
|
|
sourceTenantID, parseErr := types.ParseAgentSourceTenantID(c.Query(types.AgentSourceTenantIDParam))
|
|||
|
|
if parseErr != nil {
|
|||
|
|
return nil, ctx, errors.NewBadRequestError(parseErr.Error())
|
|||
|
|
}
|
|||
|
|
agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, tenantID, callerTenantRole, agentID, sourceTenantID)
|
|||
|
|
if err == nil && agent != nil {
|
|||
|
|
if knowledge.TenantID != agent.TenantID {
|
|||
|
|
return nil, ctx, errors.NewForbiddenError("Permission denied to access this knowledge")
|
|||
|
|
}
|
|||
|
|
mode := agent.Config.KBSelectionMode
|
|||
|
|
if mode == "none" {
|
|||
|
|
return nil, ctx, errors.NewForbiddenError("Permission denied to access this knowledge")
|
|||
|
|
}
|
|||
|
|
if mode == "all" {
|
|||
|
|
return knowledge, context.WithValue(ctx, types.TenantIDContextKey, knowledge.TenantID), nil
|
|||
|
|
}
|
|||
|
|
if mode == "selected" {
|
|||
|
|
for _, kbID := range agent.Config.KnowledgeBases {
|
|||
|
|
if kbID == knowledge.KnowledgeBaseID {
|
|||
|
|
return knowledge, context.WithValue(ctx, types.TenantIDContextKey, knowledge.TenantID), nil
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return nil, ctx, errors.NewForbiddenError("Permission denied to access this knowledge")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
kbRef := &types.KnowledgeBase{ID: knowledge.KnowledgeBaseID, TenantID: knowledge.TenantID}
|
|||
|
|
can, err := h.agentShareService.TenantCanAccessKBViaSomeSharedAgent(ctx, tenantID, callerTenantRole, kbRef)
|
|||
|
|
if err == nil && can {
|
|||
|
|
return knowledge, context.WithValue(ctx, types.TenantIDContextKey, knowledge.TenantID), nil
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
_ = userID
|
|||
|
|
_ = userExists
|
|||
|
|
return nil, ctx, errors.NewForbiddenError("Permission denied to access this knowledge")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// handleDuplicateKnowledgeError handles cases where duplicate knowledge is detected
|
|||
|
|
// Returns true if the error was a duplicate error and was handled, false otherwise
|
|||
|
|
func (h *KnowledgeHandler) handleDuplicateKnowledgeError(c *gin.Context,
|
|||
|
|
err error, knowledge *types.Knowledge, duplicateType string,
|
|||
|
|
) bool {
|
|||
|
|
if dupErr, ok := err.(*types.DuplicateKnowledgeError); ok {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
logger.Warnf(ctx, "Detected duplicate %s: %s", duplicateType, secutils.SanitizeForLog(dupErr.Error()))
|
|||
|
|
c.JSON(http.StatusConflict, gin.H{
|
|||
|
|
"success": false,
|
|||
|
|
"message": dupErr.Error(),
|
|||
|
|
"data": knowledge, // knowledge contains the existing document
|
|||
|
|
"code": fmt.Sprintf("duplicate_%s", duplicateType),
|
|||
|
|
})
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// enqueueKnowledgeListDelete enqueues an async batch-delete task for the
|
|||
|
|
// given knowledge IDs and returns the asynq task ID.
|
|||
|
|
func (h *KnowledgeHandler) enqueueKnowledgeListDelete(
|
|||
|
|
ctx context.Context, tenantID uint64, ids []string,
|
|||
|
|
) (string, error) {
|
|||
|
|
payload := types.KnowledgeListDeletePayload{
|
|||
|
|
TenantID: tenantID,
|
|||
|
|
KnowledgeIDs: ids,
|
|||
|
|
Initiator: types.TaskInitiatorFromContext(ctx),
|
|||
|
|
}
|
|||
|
|
langfuse.InjectTracing(ctx, &payload)
|
|||
|
|
payloadBytes, err := json.Marshal(payload)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", fmt.Errorf("marshal payload: %w", err)
|
|||
|
|
}
|
|||
|
|
task := asynq.NewTask(types.TypeKnowledgeListDelete, payloadBytes,
|
|||
|
|
asynq.Queue(types.QueueMaintenance), asynq.MaxRetry(3), asynq.Timeout(2*time.Hour))
|
|||
|
|
info, err := h.asynqClient.Enqueue(task)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", fmt.Errorf("enqueue task: %w", err)
|
|||
|
|
}
|
|||
|
|
return info.ID, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// enqueueKnowledgeListReparse enqueues an async batch-reparse task for the
|
|||
|
|
// given knowledge IDs and returns the asynq task ID.
|
|||
|
|
func (h *KnowledgeHandler) enqueueKnowledgeListReparse(
|
|||
|
|
ctx context.Context, tenantID uint64, ids []string, processConfig *types.KnowledgeProcessOverrides,
|
|||
|
|
) (string, error) {
|
|||
|
|
payload := types.KnowledgeListReparsePayload{
|
|||
|
|
TenantID: tenantID,
|
|||
|
|
KnowledgeIDs: ids,
|
|||
|
|
ProcessConfig: processConfig,
|
|||
|
|
Initiator: types.TaskInitiatorFromContext(ctx),
|
|||
|
|
}
|
|||
|
|
langfuse.InjectTracing(ctx, &payload)
|
|||
|
|
payloadBytes, err := json.Marshal(payload)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", fmt.Errorf("marshal payload: %w", err)
|
|||
|
|
}
|
|||
|
|
task := asynq.NewTask(types.TypeKnowledgeListReparse, payloadBytes,
|
|||
|
|
asynq.Queue(types.QueueMaintenance), asynq.MaxRetry(3), asynq.Timeout(time.Hour))
|
|||
|
|
info, err := h.asynqClient.Enqueue(task)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", fmt.Errorf("enqueue task: %w", err)
|
|||
|
|
}
|
|||
|
|
return info.ID, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CreateKnowledgeFromFile godoc
|
|||
|
|
// @Summary 从文件创建知识
|
|||
|
|
// @Description 上传文件并创建知识条目
|
|||
|
|
// @Tags 知识管理
|
|||
|
|
// @Accept multipart/form-data
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path string true "知识库ID"
|
|||
|
|
// @Param file formData file true "上传的文件"
|
|||
|
|
// @Param fileName formData string false "自定义文件名"
|
|||
|
|
// @Param metadata formData string false "元数据JSON"
|
|||
|
|
// @Param enable_multimodel formData bool false "启用多模态处理"
|
|||
|
|
// @Param tag_ids formData string false "分类ID列表,逗号分隔"
|
|||
|
|
// @Param process_config formData string false "处理配置JSON(KnowledgeProcessOverrides)"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "创建的知识"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Failure 409 {object} map[string]interface{} "文件重复"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /knowledge-bases/{id}/knowledge/file [post]
|
|||
|
|
func (h *KnowledgeHandler) CreateKnowledgeFromFile(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
logger.Info(ctx, "Start creating knowledge from file")
|
|||
|
|
|
|||
|
|
// Validate access to the knowledge base (only owner or admin/editor can create)
|
|||
|
|
_, kbID, effectiveTenantID, permission, err := h.validateKnowledgeBaseAccess(c)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
ctx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID)
|
|||
|
|
|
|||
|
|
// Check write permission
|
|||
|
|
if permission != types.OrgRoleAdmin && permission != types.OrgRoleEditor {
|
|||
|
|
c.Error(errors.NewForbiddenError("No permission to create knowledge"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Validate file size — read MAX_FILE_SIZE_MB env (50MB default).
|
|||
|
|
// Deliberately not a runtime system_setting; see filesize.go for the
|
|||
|
|
// rationale (nginx / docreader / browser bundle all cache this at
|
|||
|
|
// container startup, so a UI knob would silently mismatch).
|
|||
|
|
maxSizeMB := utils.GetMaxFileSizeMB()
|
|||
|
|
maxSize := maxSizeMB * 1024 * 1024
|
|||
|
|
// Capped before the multipart parse, not after: FormFile buffers the whole
|
|||
|
|
// body first, so the size check below only ever sees an upload we already
|
|||
|
|
// accepted. nginx location /api/ still enforces MAX_FILE_SIZE; this is the
|
|||
|
|
// same cap for requests that reach the app without that proxy.
|
|||
|
|
limitUploadBody(c, maxSize)
|
|||
|
|
|
|||
|
|
// Get the uploaded file
|
|||
|
|
file, err := c.FormFile("file")
|
|||
|
|
if err != nil {
|
|||
|
|
if isRequestBodyTooLarge(err) {
|
|||
|
|
logger.Error(ctx, "File size too large")
|
|||
|
|
c.Error(errors.NewBadRequestError(fmt.Sprintf("文件大小不能超过%dMB", maxSizeMB)))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
logger.Error(ctx, "File upload failed", err)
|
|||
|
|
c.Error(errors.NewBadRequestError("File upload failed").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if file.Size > maxSize {
|
|||
|
|
logger.Error(ctx, "File size too large")
|
|||
|
|
c.Error(errors.NewBadRequestError(fmt.Sprintf("文件大小不能超过%dMB", maxSizeMB)))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get custom filename if provided (for folder uploads with path)
|
|||
|
|
customFileName := c.PostForm("fileName")
|
|||
|
|
customFileName = secutils.SanitizeForLog(customFileName)
|
|||
|
|
displayFileName := file.Filename
|
|||
|
|
displayFileName = secutils.SanitizeForLog(displayFileName)
|
|||
|
|
if customFileName != "" {
|
|||
|
|
displayFileName = customFileName
|
|||
|
|
logger.Infof(ctx, "Using custom filename: %s (original: %s)", customFileName, displayFileName)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "File upload successful, filename: %s, size: %.2f KB", displayFileName, float64(file.Size)/1024)
|
|||
|
|
logger.Infof(ctx, "Creating knowledge, knowledge base ID: %s, filename: %s", kbID, displayFileName)
|
|||
|
|
|
|||
|
|
// Parse metadata if provided
|
|||
|
|
var metadata map[string]string
|
|||
|
|
metadataStr := c.PostForm("metadata")
|
|||
|
|
if metadataStr != "" {
|
|||
|
|
if err := json.Unmarshal([]byte(metadataStr), &metadata); err != nil {
|
|||
|
|
logger.Error(ctx, "Failed to parse metadata", err)
|
|||
|
|
c.Error(errors.NewBadRequestError("Invalid metadata format").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
logger.Infof(ctx, "Received file metadata: %s", secutils.SanitizeForLog(fmt.Sprintf("%v", metadata)))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
enableMultimodelForm := c.PostForm("enable_multimodel")
|
|||
|
|
var enableMultimodel *bool
|
|||
|
|
if enableMultimodelForm != "" {
|
|||
|
|
parseBool, err := strconv.ParseBool(enableMultimodelForm)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Error(ctx, "Failed to parse enable_multimodel", err)
|
|||
|
|
c.Error(errors.NewBadRequestError("Invalid enable_multimodel format").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
enableMultimodel = &parseBool
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var processOverrides *types.KnowledgeProcessOverrides
|
|||
|
|
if raw := c.PostForm("process_config"); raw != "" {
|
|||
|
|
processOverrides = &types.KnowledgeProcessOverrides{}
|
|||
|
|
if err := json.Unmarshal([]byte(raw), processOverrides); err != nil {
|
|||
|
|
logger.Error(ctx, "Failed to parse process_config", err)
|
|||
|
|
c.Error(errors.NewBadRequestError("Invalid process_config format").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if enableMultimodel != nil && (processOverrides == nil || processOverrides.EnableMultimodel == nil) {
|
|||
|
|
if processOverrides == nil {
|
|||
|
|
processOverrides = &types.KnowledgeProcessOverrides{EnableMultimodel: enableMultimodel}
|
|||
|
|
} else {
|
|||
|
|
processOverrides.EnableMultimodel = enableMultimodel
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取分类ID列表(如果提供),逗号分隔,用于知识多标签分类管理
|
|||
|
|
tagIDs := parseCommaSeparatedTagIDs(c.PostForm("tag_ids"))
|
|||
|
|
|
|||
|
|
channel := c.PostForm("channel")
|
|||
|
|
|
|||
|
|
// Create knowledge entry from the file
|
|||
|
|
knowledge, err := h.kgService.CreateKnowledgeFromFile(ctx, kbID, file, metadata, enableMultimodel, customFileName, tagIDs, channel, processOverrides)
|
|||
|
|
// Check for duplicate knowledge error
|
|||
|
|
if err != nil {
|
|||
|
|
if h.handleDuplicateKnowledgeError(c, err, knowledge, "file") {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
c.Error(appErr)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(
|
|||
|
|
ctx,
|
|||
|
|
"Knowledge created successfully, ID: %s, title: %s",
|
|||
|
|
secutils.SanitizeForLog(knowledge.ID),
|
|||
|
|
secutils.SanitizeForLog(knowledge.Title),
|
|||
|
|
)
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": knowledge,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CreateKnowledgeFromURL godoc
|
|||
|
|
// @Summary 从URL创建知识
|
|||
|
|
// @Description 从指定URL抓取内容并创建知识条目。当提供 file_name/file_type 或 URL 路径含已知文件扩展名时,自动切换为文件下载模式
|
|||
|
|
// @Tags 知识管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path string true "知识库ID"
|
|||
|
|
// @Param request body object{url=string,file_name=string,file_type=string,enable_multimodel=bool,title=string,tag_ids=[]string} true "URL请求"
|
|||
|
|
// @Success 201 {object} map[string]interface{} "创建的知识"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Failure 409 {object} map[string]interface{} "URL重复"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /knowledge-bases/{id}/knowledge/url [post]
|
|||
|
|
func (h *KnowledgeHandler) CreateKnowledgeFromURL(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
logger.Info(ctx, "Start creating knowledge from URL")
|
|||
|
|
|
|||
|
|
// Validate access to the knowledge base (only owner or admin/editor can create)
|
|||
|
|
_, kbID, effectiveTenantID, permission, err := h.validateKnowledgeBaseAccess(c)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
ctx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID)
|
|||
|
|
|
|||
|
|
// Check write permission
|
|||
|
|
if permission != types.OrgRoleAdmin && permission != types.OrgRoleEditor {
|
|||
|
|
c.Error(errors.NewForbiddenError("No permission to create knowledge"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Parse URL from request body
|
|||
|
|
var req struct {
|
|||
|
|
URL string `json:"url" binding:"required"`
|
|||
|
|
FileName string `json:"file_name"`
|
|||
|
|
FileType string `json:"file_type"`
|
|||
|
|
EnableMultimodel *bool `json:"enable_multimodel"`
|
|||
|
|
Title string `json:"title"`
|
|||
|
|
TagIDs []string `json:"tag_ids"`
|
|||
|
|
Channel string `json:"channel"`
|
|||
|
|
ProcessConfig *types.KnowledgeProcessOverrides `json:"process_config"`
|
|||
|
|
}
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
logger.Error(ctx, "Failed to parse URL request", err)
|
|||
|
|
c.Error(errors.NewBadRequestError(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Received URL request: %s, file_name: %s, file_type: %s",
|
|||
|
|
secutils.SanitizeForLog(req.URL),
|
|||
|
|
secutils.SanitizeForLog(req.FileName),
|
|||
|
|
secutils.SanitizeForLog(req.FileType),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// SSRF validation for user-supplied URL
|
|||
|
|
if err := secutils.ValidateURLForSSRF(req.URL); err != nil {
|
|||
|
|
logger.Warnf(ctx, "SSRF validation failed for knowledge URL: %v", err)
|
|||
|
|
c.Error(errors.NewBadRequestError(secutils.FormatSSRFError("URL", req.URL, err)))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx,
|
|||
|
|
"Creating knowledge from URL, knowledge base ID: %s, URL: %s",
|
|||
|
|
secutils.SanitizeForLog(kbID),
|
|||
|
|
secutils.SanitizeForLog(req.URL),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Create knowledge entry from the URL
|
|||
|
|
knowledge, err := h.kgService.CreateKnowledgeFromURL(
|
|||
|
|
ctx, kbID, req.URL, req.FileName, req.FileType, req.EnableMultimodel, req.Title, req.TagIDs, req.Channel, req.ProcessConfig,
|
|||
|
|
)
|
|||
|
|
// Check for duplicate knowledge error
|
|||
|
|
if err != nil {
|
|||
|
|
if h.handleDuplicateKnowledgeError(c, err, knowledge, "url") {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
c.Error(appErr)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(
|
|||
|
|
ctx,
|
|||
|
|
"Knowledge created successfully from URL, ID: %s, title: %s",
|
|||
|
|
secutils.SanitizeForLog(knowledge.ID),
|
|||
|
|
secutils.SanitizeForLog(knowledge.Title),
|
|||
|
|
)
|
|||
|
|
c.JSON(http.StatusCreated, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": knowledge,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CreateManualKnowledge godoc
|
|||
|
|
// @Summary 手工创建知识
|
|||
|
|
// @Description 手工录入Markdown格式的知识内容
|
|||
|
|
// @Tags 知识管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path string true "知识库ID"
|
|||
|
|
// @Param request body types.ManualKnowledgePayload true "手工知识内容"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "创建的知识"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /knowledge-bases/{id}/knowledge/manual [post]
|
|||
|
|
func (h *KnowledgeHandler) CreateManualKnowledge(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
logger.Info(ctx, "Start creating manual knowledge")
|
|||
|
|
|
|||
|
|
// Validate access to the knowledge base (only owner or admin/editor can create)
|
|||
|
|
_, kbID, effectiveTenantID, permission, err := h.validateKnowledgeBaseAccess(c)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
ctx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID)
|
|||
|
|
|
|||
|
|
// Check write permission
|
|||
|
|
if permission != types.OrgRoleAdmin && permission != types.OrgRoleEditor {
|
|||
|
|
c.Error(errors.NewForbiddenError("No permission to create knowledge"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var req types.ManualKnowledgePayload
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
logger.Error(ctx, "Failed to parse manual knowledge request", err)
|
|||
|
|
c.Error(errors.NewBadRequestError(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
knowledge, err := h.kgService.CreateKnowledgeFromManual(ctx, kbID, &req, req.Channel)
|
|||
|
|
if err != nil {
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
c.Error(appErr)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|||
|
|
"kb_id": kbID,
|
|||
|
|
})
|
|||
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Manual knowledge created successfully, knowledge ID: %s",
|
|||
|
|
secutils.SanitizeForLog(knowledge.ID))
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": knowledge,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetKnowledge godoc
|
|||
|
|
// @Summary 获取知识详情
|
|||
|
|
// @Description 根据ID获取知识条目详情
|
|||
|
|
// @Tags 知识管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @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/{id} [get]
|
|||
|
|
func (h *KnowledgeHandler) GetKnowledge(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
logger.Info(ctx, "Start retrieving knowledge")
|
|||
|
|
|
|||
|
|
id := secutils.SanitizeForLog(c.Param("id"))
|
|||
|
|
if id == "" {
|
|||
|
|
logger.Error(ctx, "Knowledge ID is empty")
|
|||
|
|
c.Error(errors.NewBadRequestError("Knowledge ID cannot be empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Resolve knowledge and validate KB access (at least viewer)
|
|||
|
|
knowledge, effCtx, err := h.resolveKnowledgeAndValidateKBAccess(c, id, types.OrgRoleViewer)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Re-fetch with tenant-scoped service so tags and other joined fields are populated.
|
|||
|
|
if knowledge, err = h.kgService.GetKnowledgeByID(effCtx, id); err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewNotFoundError("Knowledge not found"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Knowledge retrieved successfully, ID: %s, title: %s",
|
|||
|
|
secutils.SanitizeForLog(knowledge.ID), secutils.SanitizeForLog(knowledge.Title))
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": knowledge,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetKnowledgeSpans godoc
|
|||
|
|
// @Summary 获取知识文档解析的 Span 树(含历史尝试)
|
|||
|
|
// @Description 返回该知识在解析流水线的 trace tree(root → stage → subspan):每段状态、耗时、input/output、错误码、langfuse_trace_id。支持 ?attempt=N 查看历史尝试;不传则返回最新尝试。前端用于渲染时间线 + 多模态/embedding 子节点 + 一键跳转 Langfuse。
|
|||
|
|
// @Tags 知识管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path string true "知识ID"
|
|||
|
|
// @Param attempt query int false "指定尝试号;省略=最新"
|
|||
|
|
// @Success 200 {object} map[string]interface{}
|
|||
|
|
// @Router /api/v1/knowledge/{id}/spans [get]
|
|||
|
|
//
|
|||
|
|
// Always returns the canonical 5-stage timeline; missing stage rows are
|
|||
|
|
// synthesized as "pending" so the frontend timeline always renders five
|
|||
|
|
// segments. Subspans (multimodal.image[i], generation.*) ride along under
|
|||
|
|
// each stage as children when present.
|
|||
|
|
func (h *KnowledgeHandler) GetKnowledgeSpans(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
id := secutils.SanitizeForLog(c.Param("id"))
|
|||
|
|
if id == "" {
|
|||
|
|
c.Error(errors.NewBadRequestError("Knowledge ID cannot be empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
knowledge, _, err := h.resolveKnowledgeAndValidateKBAccess(c, id, types.OrgRoleViewer)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Pick attempt: explicit ?attempt=N wins; otherwise pull the
|
|||
|
|
// latest attempt from the spans table. Lite-mode / fresh installs
|
|||
|
|
// with zero rows fall through to attempt=0, in which case we
|
|||
|
|
// return a placeholder tree (5 pending stages, no root, no
|
|||
|
|
// children) so the UI still renders.
|
|||
|
|
requestedAttempt := 0
|
|||
|
|
if v := strings.TrimSpace(c.Query("attempt")); v != "" {
|
|||
|
|
if n, perr := strconv.Atoi(v); perr == nil && n > 0 {
|
|||
|
|
requestedAttempt = n
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
rows := []types.KnowledgeProcessingSpan{}
|
|||
|
|
currentAttempt := 0
|
|||
|
|
latestAttempt := 0
|
|||
|
|
if h.spanRepo != nil {
|
|||
|
|
latest, lerr := h.spanRepo.LatestAttempt(ctx, knowledge.ID)
|
|||
|
|
if lerr != nil {
|
|||
|
|
logger.Warnf(ctx, "spans LatestAttempt failed for %s: %v", knowledge.ID, lerr)
|
|||
|
|
} else {
|
|||
|
|
latestAttempt = latest
|
|||
|
|
}
|
|||
|
|
if requestedAttempt > 0 {
|
|||
|
|
currentAttempt = requestedAttempt
|
|||
|
|
} else {
|
|||
|
|
currentAttempt = latestAttempt
|
|||
|
|
}
|
|||
|
|
if currentAttempt > 0 {
|
|||
|
|
rows, err = h.spanRepo.ListByAttempt(ctx, knowledge.ID, currentAttempt)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Warnf(ctx, "spans ListByAttempt failed kid=%s attempt=%d: %v",
|
|||
|
|
knowledge.ID, currentAttempt, err)
|
|||
|
|
rows = nil
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Build tree: index by SpanID, then attach to parents. Stages
|
|||
|
|
// missing from the DB are synthesized as "pending" placeholders
|
|||
|
|
// under a synthetic (or real, if present) root so the timeline
|
|||
|
|
// always renders five segments. parse_status threads through so
|
|||
|
|
// pre-tracker historical knowledge (no rows but parse_status is
|
|||
|
|
// already terminal) renders as done/failed instead of pending —
|
|||
|
|
// otherwise legacy completed documents would forever look like
|
|||
|
|
// they're still waiting in the queue.
|
|||
|
|
tree, currentStageName, lastErr := buildSpanTree(knowledge.ID, currentAttempt, rows, knowledge.ParseStatus)
|
|||
|
|
|
|||
|
|
resp := gin.H{
|
|||
|
|
"knowledge_id": knowledge.ID,
|
|||
|
|
"attempt": currentAttempt,
|
|||
|
|
"latest_attempt": latestAttempt,
|
|||
|
|
"parse_status": knowledge.ParseStatus,
|
|||
|
|
"current_attempt": currentAttempt,
|
|||
|
|
"current_stage": currentStageName,
|
|||
|
|
"trace": tree,
|
|||
|
|
}
|
|||
|
|
if lastError := knowledgeSpansLastError(
|
|||
|
|
currentAttempt,
|
|||
|
|
latestAttempt,
|
|||
|
|
knowledge.ParseStatus,
|
|||
|
|
knowledge.ErrorMessage,
|
|||
|
|
knowledge.UpdatedAt,
|
|||
|
|
lastErr,
|
|||
|
|
); lastError != nil {
|
|||
|
|
resp["last_error"] = lastError
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": resp,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// knowledgeSpansLastError builds the last_error payload for GetKnowledgeSpans.
|
|||
|
|
// Span failures win when present; otherwise a failed knowledge row without a
|
|||
|
|
// matching span error (recovery / dead-letter paths) surfaces ErrorMessage.
|
|||
|
|
func knowledgeSpansLastError(
|
|||
|
|
currentAttempt, latestAttempt int,
|
|||
|
|
parseStatus, knowledgeErrorMessage string,
|
|||
|
|
knowledgeUpdatedAt time.Time,
|
|||
|
|
spanFailure *types.KnowledgeProcessingSpan,
|
|||
|
|
) gin.H {
|
|||
|
|
if spanFailure != nil {
|
|||
|
|
return gin.H{
|
|||
|
|
"stage": spanFailure.Name,
|
|||
|
|
"code": spanFailure.ErrorCode,
|
|||
|
|
"message": spanFailure.ErrorMessage,
|
|||
|
|
"name": spanFailure.Name,
|
|||
|
|
"error_code": spanFailure.ErrorCode,
|
|||
|
|
"error_message": spanFailure.ErrorMessage,
|
|||
|
|
"finished_at": spanFailure.FinishedAt,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if currentAttempt != latestAttempt || parseStatus != types.ParseStatusFailed || knowledgeErrorMessage == "" {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
errorCode := "UNKNOWN"
|
|||
|
|
if strings.EqualFold(strings.TrimSpace(knowledgeErrorMessage),
|
|||
|
|
"Task interrupted due to application restart") {
|
|||
|
|
errorCode = "SERVER_RESTART"
|
|||
|
|
}
|
|||
|
|
return gin.H{
|
|||
|
|
"stage": "knowledge_processing",
|
|||
|
|
"code": errorCode,
|
|||
|
|
"message": knowledgeErrorMessage,
|
|||
|
|
"name": "knowledge_processing",
|
|||
|
|
"error_code": errorCode,
|
|||
|
|
"error_message": knowledgeErrorMessage,
|
|||
|
|
"finished_at": knowledgeUpdatedAt,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// buildSpanTree assembles a flat list of span rows into a parent-child
|
|||
|
|
// tree rooted at the (knowledge, attempt)'s root span. Missing canonical
|
|||
|
|
// stages are filled in with pending placeholders so the UI always renders
|
|||
|
|
// the five timeline segments. Returns the root, the current_stage name
|
|||
|
|
// (the running stage if any), and the most recent failed span if one
|
|||
|
|
// exists.
|
|||
|
|
//
|
|||
|
|
// parseStatus is the knowledge.parse_status string. When the spans table
|
|||
|
|
// has zero rows for this attempt (legacy data parsed before tracking, or
|
|||
|
|
// a fresh knowledge before the pipeline starts), the placeholder status
|
|||
|
|
// is inferred from parseStatus: completed → done, failed → failed,
|
|||
|
|
// otherwise pending. Without this, every historical knowledge would
|
|||
|
|
// render as "all 5 stages pending" forever despite having actually
|
|||
|
|
// completed parsing.
|
|||
|
|
func buildSpanTree(knowledgeID string, attempt int, rows []types.KnowledgeProcessingSpan, parseStatus string) (
|
|||
|
|
root *types.SpanTreeNode, currentStage string, lastFailure *types.KnowledgeProcessingSpan,
|
|||
|
|
) {
|
|||
|
|
now := time.Now()
|
|||
|
|
// Build node lookup, identify root.
|
|||
|
|
nodes := make(map[string]*types.SpanTreeNode, len(rows))
|
|||
|
|
var rootRow *types.KnowledgeProcessingSpan
|
|||
|
|
stageRowByName := map[string]*types.KnowledgeProcessingSpan{}
|
|||
|
|
for i := range rows {
|
|||
|
|
r := rows[i]
|
|||
|
|
nodes[r.SpanID] = &types.SpanTreeNode{KnowledgeProcessingSpan: r}
|
|||
|
|
if r.Kind == types.SpanKindRoot && rootRow == nil {
|
|||
|
|
cp := r
|
|||
|
|
rootRow = &cp
|
|||
|
|
}
|
|||
|
|
if r.Kind == types.SpanKindStage {
|
|||
|
|
cp := r
|
|||
|
|
stageRowByName[r.Name] = &cp
|
|||
|
|
}
|
|||
|
|
if r.Status == types.SpanStatusRunning && r.Kind == types.SpanKindStage && currentStage == "" {
|
|||
|
|
currentStage = r.Name
|
|||
|
|
}
|
|||
|
|
if r.Status == types.SpanStatusFailed {
|
|||
|
|
cp := r
|
|||
|
|
lastFailure = &cp
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Pick the synthesized stage status from parse_status. Without this,
|
|||
|
|
// historical knowledge that completed before span tracking was wired
|
|||
|
|
// would render as "5 pending stages" forever — the rows simply
|
|||
|
|
// weren't recorded, but parse_status correctly reads "completed".
|
|||
|
|
// The synthesized stages don't carry duration/timing data; they
|
|||
|
|
// just communicate the inferred terminal state.
|
|||
|
|
syntheticStatus := types.SpanStatusPending
|
|||
|
|
switch parseStatus {
|
|||
|
|
case types.ParseStatusCompleted:
|
|||
|
|
syntheticStatus = types.SpanStatusDone
|
|||
|
|
case types.ParseStatusFailed:
|
|||
|
|
syntheticStatus = types.SpanStatusFailed
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Synthesize root if no rows came back so the API contract stays
|
|||
|
|
// stable (frontend always expects a `trace` object).
|
|||
|
|
if rootRow == nil {
|
|||
|
|
root = &types.SpanTreeNode{KnowledgeProcessingSpan: types.KnowledgeProcessingSpan{
|
|||
|
|
KnowledgeID: knowledgeID,
|
|||
|
|
Attempt: attempt,
|
|||
|
|
SpanID: "",
|
|||
|
|
Name: "knowledge_processing",
|
|||
|
|
Kind: types.SpanKindRoot,
|
|||
|
|
Status: syntheticStatus,
|
|||
|
|
CreatedAt: now,
|
|||
|
|
UpdatedAt: now,
|
|||
|
|
}}
|
|||
|
|
} else {
|
|||
|
|
root = nodes[rootRow.SpanID]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Link real children to their parents. Walk `rows` (not the
|
|||
|
|
// `nodes` map!) so the append order matches the repo's stable
|
|||
|
|
// `ORDER BY id ASC`. Iterating the map directly would give
|
|||
|
|
// callers a different child ordering on every request — Go map
|
|||
|
|
// iteration is intentionally randomised — and the UI would
|
|||
|
|
// flicker subspans into a different order on each refresh.
|
|||
|
|
for i := range rows {
|
|||
|
|
r := &rows[i]
|
|||
|
|
n := nodes[r.SpanID]
|
|||
|
|
if n == nil || n == root {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if r.ParentSpanID == "" {
|
|||
|
|
// Real top-level row with no parent and not the root
|
|||
|
|
// itself — attach to root so it doesn't dangle.
|
|||
|
|
root.Children = append(root.Children, n)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
parent, ok := nodes[r.ParentSpanID]
|
|||
|
|
if !ok {
|
|||
|
|
// Unknown parent (orphan); attach to root.
|
|||
|
|
root.Children = append(root.Children, n)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
parent.Children = append(parent.Children, n)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Synthesize missing stage rows as children of root so the timeline
|
|||
|
|
// always shows 5 segments. Status mirrors the synthesized root —
|
|||
|
|
// pending while the pipeline is still running, done/failed for
|
|||
|
|
// historical knowledge whose terminal state we know but whose
|
|||
|
|
// per-stage timing was never recorded. Appended in AllStages order
|
|||
|
|
// so the canonical stage layout is deterministic regardless of
|
|||
|
|
// which rows are missing.
|
|||
|
|
for _, name := range types.AllStages {
|
|||
|
|
if _, ok := stageRowByName[name]; ok {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
placeholder := types.KnowledgeProcessingSpan{
|
|||
|
|
KnowledgeID: knowledgeID,
|
|||
|
|
Attempt: attempt,
|
|||
|
|
Name: name,
|
|||
|
|
Kind: types.SpanKindStage,
|
|||
|
|
Status: syntheticStatus,
|
|||
|
|
CreatedAt: now,
|
|||
|
|
UpdatedAt: now,
|
|||
|
|
}
|
|||
|
|
root.Children = append(root.Children, &types.SpanTreeNode{KnowledgeProcessingSpan: placeholder})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return root, currentStage, lastFailure
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListKnowledge godoc
|
|||
|
|
// @Summary 获取知识列表
|
|||
|
|
// @Description 获取知识库下的知识列表,支持分页和筛选
|
|||
|
|
// @Tags 知识管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path string true "知识库ID"
|
|||
|
|
// @Param page query int false "页码"
|
|||
|
|
// @Param page_size query int false "每页数量"
|
|||
|
|
// @Param tag_ids query string false "标签ID筛选,逗号分隔(OR语义)"
|
|||
|
|
// @Param keyword query string false "关键词搜索"
|
|||
|
|
// @Param file_type query string false "文件类型筛选"
|
|||
|
|
// @Param parse_status query string false "解析状态筛选 (pending/processing/completed/failed)"
|
|||
|
|
// @Param source query string false "来源/渠道筛选 (web/api/feishu/notion/yuque/wechat/...,或 manual/url 按 type 过滤)"
|
|||
|
|
// @Param start_time query string false "更新时间起点,RFC3339 格式"
|
|||
|
|
// @Param end_time query string false "更新时间终点,RFC3339 格式"
|
|||
|
|
// @Param folder_path query string false "文件夹路径筛选,空字符串表示知识库根目录;不传该参数则不按文件夹过滤"
|
|||
|
|
// @Param folder_recursive query bool false "为 true 时同时返回子文件夹内的文档"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "知识列表"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /knowledge-bases/{id}/knowledge [get]
|
|||
|
|
func (h *KnowledgeHandler) ListKnowledge(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
logger.Info(ctx, "Start retrieving knowledge list")
|
|||
|
|
|
|||
|
|
// Validate access to the knowledge base (read access - any permission level)
|
|||
|
|
_, kbID, effectiveTenantID, _, err := h.validateKnowledgeBaseAccess(c)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Update context with effective tenant ID for shared KB access
|
|||
|
|
ctx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID)
|
|||
|
|
|
|||
|
|
// Parse pagination parameters from query string
|
|||
|
|
var pagination types.Pagination
|
|||
|
|
if err := c.ShouldBindQuery(&pagination); err != nil {
|
|||
|
|
logger.Error(ctx, "Failed to parse pagination parameters", err)
|
|||
|
|
c.Error(errors.NewBadRequestError(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
filter := types.KnowledgeListFilter{
|
|||
|
|
TagIDs: parseCommaSeparatedTagIDs(c.Query("tag_ids")),
|
|||
|
|
Keyword: c.Query("keyword"),
|
|||
|
|
FileType: c.Query("file_type"),
|
|||
|
|
ParseStatus: c.Query("parse_status"),
|
|||
|
|
Source: c.Query("source"),
|
|||
|
|
}
|
|||
|
|
if raw := c.Query("start_time"); raw != "" {
|
|||
|
|
t, err := parseFilterTime(raw)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(errors.NewBadRequestError("invalid start_time: " + err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
filter.UpdatedFrom = t
|
|||
|
|
}
|
|||
|
|
if raw := c.Query("end_time"); raw != "" {
|
|||
|
|
t, err := parseFilterTime(raw)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(errors.NewBadRequestError("invalid end_time: " + err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
filter.UpdatedTo = t
|
|||
|
|
}
|
|||
|
|
// The folder dimension is opt-in by parameter *presence*: an empty
|
|||
|
|
// folder_path is meaningful (the knowledge base root), so it cannot be
|
|||
|
|
// distinguished from "no folder filter" by value alone.
|
|||
|
|
if raw, ok := c.GetQuery("folder_path"); ok {
|
|||
|
|
filter.FolderPath = types.NormalizeKnowledgeFolderPath(raw)
|
|||
|
|
filter.FolderScope = types.FolderScopeExact
|
|||
|
|
if recursive, err := strconv.ParseBool(c.DefaultQuery("folder_recursive", "false")); err == nil && recursive {
|
|||
|
|
filter.FolderScope = types.FolderScopeSubtree
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(
|
|||
|
|
ctx,
|
|||
|
|
"Retrieving knowledge list under knowledge base, kb_id=%s tag_ids=%s keyword=%s file_type=%s parse_status=%s source=%s start_time=%s end_time=%s folder_path=%s folder_scope=%s page=%d page_size=%d effectiveTenantID=%d",
|
|||
|
|
secutils.SanitizeForLog(kbID),
|
|||
|
|
secutils.SanitizeForLog(strings.Join(filter.TagIDs, ",")),
|
|||
|
|
secutils.SanitizeForLog(filter.Keyword),
|
|||
|
|
secutils.SanitizeForLog(filter.FileType),
|
|||
|
|
secutils.SanitizeForLog(filter.ParseStatus),
|
|||
|
|
secutils.SanitizeForLog(filter.Source),
|
|||
|
|
secutils.SanitizeForLog(c.Query("start_time")),
|
|||
|
|
secutils.SanitizeForLog(c.Query("end_time")),
|
|||
|
|
secutils.SanitizeForLog(filter.FolderPath),
|
|||
|
|
string(filter.FolderScope),
|
|||
|
|
pagination.Page,
|
|||
|
|
pagination.PageSize,
|
|||
|
|
effectiveTenantID,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Retrieve paginated knowledge entries
|
|||
|
|
result, err := h.kgService.ListPagedKnowledgeByKnowledgeBaseID(ctx, kbID, &pagination, filter)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(
|
|||
|
|
ctx,
|
|||
|
|
"Knowledge list retrieved successfully, knowledge base ID: %s, total: %d",
|
|||
|
|
secutils.SanitizeForLog(kbID),
|
|||
|
|
result.Total,
|
|||
|
|
)
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": result.Data,
|
|||
|
|
"total": result.Total,
|
|||
|
|
"page": result.Page,
|
|||
|
|
"page_size": result.PageSize,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListKnowledgeFolders 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}/knowledge/folders [get]
|
|||
|
|
func (h *KnowledgeHandler) ListKnowledgeFolders(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
// Read access mirrors ListKnowledge so the sidebar tree is available to
|
|||
|
|
// every viewer of a shared knowledge base.
|
|||
|
|
_, kbID, effectiveTenantID, _, err := h.validateKnowledgeBaseAccess(c)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
ctx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID)
|
|||
|
|
|
|||
|
|
tree, err := h.kgService.ListKnowledgeFolderTree(ctx, kbID)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Knowledge folder tree retrieved, kb_id=%s folders=%d",
|
|||
|
|
secutils.SanitizeForLog(kbID), len(tree.Folders))
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": tree,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MoveKnowledgeToFolderRequest is the body schema for POST /knowledge/folder.
|
|||
|
|
type MoveKnowledgeToFolderRequest struct {
|
|||
|
|
KBID string `json:"kb_id" binding:"required"`
|
|||
|
|
IDs []string `json:"knowledge_ids" binding:"required"`
|
|||
|
|
// FolderPath is the destination folder; the empty string is the knowledge
|
|||
|
|
// base top level. It is deliberately not `binding:"required"` so documents
|
|||
|
|
// can be moved back out of every folder.
|
|||
|
|
FolderPath string `json:"folder_path"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MoveKnowledgeToFolder godoc
|
|||
|
|
// @Summary 移动知识到文件夹
|
|||
|
|
// @Description 批量修改知识条目所属文件夹。文件夹由路径推导而来,因此目标路径不存在时会自动创建;空路径表示知识库顶层。仅调整归类,不会重新解析文档
|
|||
|
|
// @Tags 知识管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param request body MoveKnowledgeToFolderRequest true "移动请求"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "移动成功"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Failure 403 {object} errors.AppError "权限不足"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /knowledge/folder [post]
|
|||
|
|
func (h *KnowledgeHandler) MoveKnowledgeToFolder(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
var req MoveKnowledgeToFolderRequest
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
c.Error(errors.NewBadRequestError("Invalid request parameters: " + err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ids := dedupeKnowledgeIDs(req.IDs)
|
|||
|
|
if len(ids) == 0 {
|
|||
|
|
c.Error(errors.NewBadRequestError("knowledge_ids cannot be empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
const maxBatch = 200
|
|||
|
|
if len(ids) > maxBatch {
|
|||
|
|
c.Error(errors.NewBadRequestError(fmt.Sprintf("too many ids (max %d per batch)", maxBatch)))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
kbID, effectiveTenantID, err := h.requireKnowledgeWriteAccess(c, req.KBID)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
ctx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID)
|
|||
|
|
|
|||
|
|
// Guard against cross-KB moves: the service layer scopes by tenant, so the
|
|||
|
|
// handler must confirm every entry belongs to the requested knowledge base.
|
|||
|
|
if err := h.requireKnowledgeInKB(ctx, effectiveTenantID, kbID, ids); err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
affected, err := h.kgService.MoveKnowledgeToFolder(ctx, kbID, ids, req.FolderPath)
|
|||
|
|
if err != nil {
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
c.Error(appErr)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": gin.H{
|
|||
|
|
"moved_count": affected,
|
|||
|
|
"folder_path": types.NormalizeKnowledgeFolderPath(req.FolderPath),
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// RenameKnowledgeFolderRequest is the body schema for
|
|||
|
|
// PUT /knowledge-bases/:id/knowledge/folders.
|
|||
|
|
type RenameKnowledgeFolderRequest struct {
|
|||
|
|
From string `json:"from" binding:"required"`
|
|||
|
|
To string `json:"to" binding:"required"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// RenameKnowledgeFolder godoc
|
|||
|
|
// @Summary 重命名或移动文件夹
|
|||
|
|
// @Description 把一个文件夹及其所有子目录改到新路径。目标路径已存在时两个文件夹合并;不能移动到自身子目录下
|
|||
|
|
// @Tags 知识管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path string true "知识库ID"
|
|||
|
|
// @Param request body RenameKnowledgeFolderRequest true "重命名请求"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "重命名成功"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Failure 403 {object} errors.AppError "权限不足"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /knowledge-bases/{id}/knowledge/folders [put]
|
|||
|
|
func (h *KnowledgeHandler) RenameKnowledgeFolder(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
var req RenameKnowledgeFolderRequest
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
c.Error(errors.NewBadRequestError("Invalid request parameters: " + err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_, kbID, effectiveTenantID, permission, err := h.validateKnowledgeBaseAccess(c)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if permission != types.OrgRoleAdmin && permission != types.OrgRoleEditor {
|
|||
|
|
c.Error(errors.NewForbiddenError("No permission to modify knowledge"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if err := h.requireKBOwnershipOrAdmin(c, kbID); err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
ctx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID)
|
|||
|
|
|
|||
|
|
affected, err := h.kgService.RenameKnowledgeFolder(ctx, kbID, req.From, req.To)
|
|||
|
|
if err != nil {
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
c.Error(appErr)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": gin.H{
|
|||
|
|
"moved_count": affected,
|
|||
|
|
"folder_path": types.NormalizeKnowledgeFolderPath(req.To),
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// dedupeKnowledgeIDs trims, drops empty and de-duplicates a batch of IDs while
|
|||
|
|
// preserving the caller's order.
|
|||
|
|
func dedupeKnowledgeIDs(raw []string) []string {
|
|||
|
|
seen := make(map[string]struct{}, len(raw))
|
|||
|
|
ids := make([]string, 0, len(raw))
|
|||
|
|
for _, item := range raw {
|
|||
|
|
id := strings.TrimSpace(item)
|
|||
|
|
if id == "" {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if _, ok := seen[id]; ok {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
seen[id] = struct{}{}
|
|||
|
|
ids = append(ids, id)
|
|||
|
|
}
|
|||
|
|
return ids
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// requireKnowledgeWriteAccess resolves a knowledge base from a request body and
|
|||
|
|
// enforces the editor-or-admin plus ownership gate shared by the batch routes.
|
|||
|
|
func (h *KnowledgeHandler) requireKnowledgeWriteAccess(
|
|||
|
|
c *gin.Context,
|
|||
|
|
requestedKBID string,
|
|||
|
|
) (string, uint64, error) {
|
|||
|
|
_, kbID, effectiveTenantID, permission, err := h.validateKnowledgeBaseAccessWithKBID(c, requestedKBID)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", 0, err
|
|||
|
|
}
|
|||
|
|
if permission != types.OrgRoleAdmin && permission != types.OrgRoleEditor {
|
|||
|
|
return "", 0, errors.NewForbiddenError("No permission to modify knowledge")
|
|||
|
|
}
|
|||
|
|
if err := h.requireKBOwnershipOrAdmin(c, kbID); err != nil {
|
|||
|
|
return "", 0, err
|
|||
|
|
}
|
|||
|
|
return kbID, effectiveTenantID, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// requireKnowledgeInKB verifies every ID exists and belongs to the given
|
|||
|
|
// knowledge base, so a batch operation cannot reach across knowledge bases.
|
|||
|
|
func (h *KnowledgeHandler) requireKnowledgeInKB(
|
|||
|
|
ctx context.Context,
|
|||
|
|
tenantID uint64,
|
|||
|
|
kbID string,
|
|||
|
|
ids []string,
|
|||
|
|
) error {
|
|||
|
|
knowledgeList, err := h.kgService.GetKnowledgeBatch(ctx, tenantID, ids)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
return errors.NewInternalServerError(err.Error())
|
|||
|
|
}
|
|||
|
|
if len(knowledgeList) != len(ids) {
|
|||
|
|
return errors.NewBadRequestError("One or more knowledge entries not found")
|
|||
|
|
}
|
|||
|
|
for _, k := range knowledgeList {
|
|||
|
|
if k.KnowledgeBaseID != kbID {
|
|||
|
|
return errors.NewBadRequestError(
|
|||
|
|
fmt.Sprintf("Knowledge %s does not belong to knowledge base %s",
|
|||
|
|
secutils.SanitizeForLog(k.ID), secutils.SanitizeForLog(kbID)))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DeleteKnowledge godoc
|
|||
|
|
// @Summary 删除知识
|
|||
|
|
// @Description 根据ID异步删除知识条目。请求会被入队到与批量删除相同的异步管道(asynq);
|
|||
|
|
// @Description 接口返回 200 仅表示任务已提交(响应 data.task_id 为任务 ID),实际删除由后台 worker 完成。
|
|||
|
|
// @Tags 知识管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path string true "知识ID"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "任务已提交,返回 task_id"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /knowledge/{id} [delete]
|
|||
|
|
func (h *KnowledgeHandler) DeleteKnowledge(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
logger.Info(ctx, "Start deleting knowledge")
|
|||
|
|
|
|||
|
|
id := secutils.SanitizeForLog(c.Param("id"))
|
|||
|
|
if id == "" {
|
|||
|
|
logger.Error(ctx, "Knowledge ID is empty")
|
|||
|
|
c.Error(errors.NewBadRequestError("Knowledge ID cannot be empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_, effCtx, err := h.resolveKnowledgeAndValidateKBAccess(c, id, types.OrgRoleEditor)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Reuse the batch async pipeline so single-item delete shares the same
|
|||
|
|
// hardening (asynq retries, business-aware queue routing, marking-as-deleting
|
|||
|
|
// inside the worker) as BatchDeleteKnowledge / ClearKnowledgeBaseContents.
|
|||
|
|
effectiveTenantID, _ := effCtx.Value(types.TenantIDContextKey).(uint64)
|
|||
|
|
if effectiveTenantID == 0 {
|
|||
|
|
logger.Error(ctx, "Effective tenant ID missing after access validation")
|
|||
|
|
c.Error(errors.NewInternalServerError("workspace context unavailable"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Enqueuing knowledge delete, ID: %s", secutils.SanitizeForLog(id))
|
|||
|
|
taskID, err := h.enqueueKnowledgeListDelete(effCtx, effectiveTenantID, []string{id})
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Errorf(ctx, "Failed to enqueue knowledge delete task: %v", err)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to enqueue delete task"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Knowledge delete task enqueued: %s, knowledge_id: %s", taskID, secutils.SanitizeForLog(id))
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"message": "Delete task submitted",
|
|||
|
|
"data": gin.H{
|
|||
|
|
"task_id": taskID,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// BatchDeleteKnowledgeRequest is the body schema for POST /knowledge/batch-delete.
|
|||
|
|
type BatchDeleteKnowledgeRequest struct {
|
|||
|
|
KBID string `json:"kb_id" binding:"required"`
|
|||
|
|
IDs []string `json:"ids" binding:"required"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// BatchDeleteKnowledge godoc
|
|||
|
|
// @Summary 批量删除知识
|
|||
|
|
// @Description 按 ID 列表批量删除单个知识库下的多个知识条目
|
|||
|
|
// @Tags 知识管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param request body BatchDeleteKnowledgeRequest true "批量删除请求"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "删除成功"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Failure 403 {object} errors.AppError "权限不足"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /knowledge/batch-delete [post]
|
|||
|
|
func (h *KnowledgeHandler) BatchDeleteKnowledge(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
var req BatchDeleteKnowledgeRequest
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
c.Error(errors.NewBadRequestError("Invalid request parameters: " + err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ids := dedupeKnowledgeIDs(req.IDs)
|
|||
|
|
if len(ids) == 0 {
|
|||
|
|
c.Error(errors.NewBadRequestError("ids cannot be empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
const maxBatch = 200
|
|||
|
|
if len(ids) > maxBatch {
|
|||
|
|
c.Error(errors.NewBadRequestError(fmt.Sprintf("too many ids (max %d per batch)", maxBatch)))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Validate KB access (editor or admin) using the kb_id from body.
|
|||
|
|
_, kbID, effectiveTenantID, permission, err := h.validateKnowledgeBaseAccessWithKBID(c, req.KBID)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if permission != types.OrgRoleAdmin && permission != types.OrgRoleEditor {
|
|||
|
|
c.Error(errors.NewForbiddenError("No permission to delete knowledge"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if err := h.requireKBOwnershipOrAdmin(c, kbID); err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
ctx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID)
|
|||
|
|
|
|||
|
|
// Single batch fetch to validate that every id exists and belongs to the
|
|||
|
|
// requested KB. The service-layer DeleteKnowledgeList only enforces tenant
|
|||
|
|
// scope, not KB scope, so the handler must guard against cross-KB deletion.
|
|||
|
|
knowledgeList, err := h.kgService.GetKnowledgeBatch(ctx, effectiveTenantID, ids)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if len(knowledgeList) != len(ids) {
|
|||
|
|
c.Error(errors.NewBadRequestError("One or more knowledge entries not found"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
for _, k := range knowledgeList {
|
|||
|
|
if k.KnowledgeBaseID != kbID {
|
|||
|
|
c.Error(errors.NewBadRequestError(
|
|||
|
|
fmt.Sprintf("Knowledge %s does not belong to knowledge base %s",
|
|||
|
|
secutils.SanitizeForLog(k.ID), secutils.SanitizeForLog(kbID))))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
taskID, err := h.enqueueKnowledgeListDelete(ctx, effectiveTenantID, ids)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Errorf(ctx, "Failed to enqueue batch knowledge delete task: %v", err)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to enqueue batch delete task"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Batch knowledge delete task enqueued: %s, kb_id: %s, count: %d",
|
|||
|
|
taskID, secutils.SanitizeForLog(kbID), len(ids))
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"message": "Batch delete task submitted",
|
|||
|
|
"data": gin.H{
|
|||
|
|
"task_id": taskID,
|
|||
|
|
"deleted_count": len(ids),
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ClearKnowledgeBaseContents 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 "请求参数错误"
|
|||
|
|
// @Failure 403 {object} errors.AppError "权限不足"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /knowledge-bases/{id}/knowledge [delete]
|
|||
|
|
func (h *KnowledgeHandler) ClearKnowledgeBaseContents(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
logger.Info(ctx, "Start clearing knowledge base contents")
|
|||
|
|
|
|||
|
|
kb, kbID, effectiveTenantID, permission, err := h.validateKnowledgeBaseAccess(c)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Only owner (admin with matching tenant) can clear knowledge base contents
|
|||
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|||
|
|
if kb.TenantID != tenantID || permission != types.OrgRoleAdmin {
|
|||
|
|
c.Error(errors.NewForbiddenError("Only knowledge base owner can clear contents"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ctx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID)
|
|||
|
|
|
|||
|
|
knowledgeList, err := h.kgService.ListKnowledgeByKnowledgeBaseID(ctx, kbID)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to list knowledge entries").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if len(knowledgeList) == 0 {
|
|||
|
|
logger.Infof(ctx, "Knowledge base %s is already empty", secutils.SanitizeForLog(kbID))
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"message": "Knowledge base is already empty",
|
|||
|
|
"data": gin.H{"deleted_count": 0},
|
|||
|
|
})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
knowledgeIDs := make([]string, 0, len(knowledgeList))
|
|||
|
|
for _, knowledge := range knowledgeList {
|
|||
|
|
knowledgeIDs = append(knowledgeIDs, knowledge.ID)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
taskID, err := h.enqueueKnowledgeListDelete(ctx, effectiveTenantID, knowledgeIDs)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Errorf(ctx, "Failed to enqueue knowledge list delete task: %v", err)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to enqueue cleanup task"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Knowledge base contents clear task enqueued: %s, kb_id: %s, count: %d",
|
|||
|
|
taskID, secutils.SanitizeForLog(kbID), len(knowledgeIDs))
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"message": "Knowledge base contents clear task submitted",
|
|||
|
|
"data": gin.H{"deleted_count": len(knowledgeIDs)},
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DownloadKnowledgeFile godoc
|
|||
|
|
// @Summary 下载知识文件
|
|||
|
|
// @Description 下载知识条目关联的原始文件
|
|||
|
|
// @Tags 知识管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce application/octet-stream
|
|||
|
|
// @Param id path string true "知识ID"
|
|||
|
|
// @Success 200 {file} file "文件内容"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /knowledge/{id}/download [get]
|
|||
|
|
func (h *KnowledgeHandler) DownloadKnowledgeFile(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
logger.Info(ctx, "Start downloading knowledge file")
|
|||
|
|
|
|||
|
|
id := secutils.SanitizeForLog(c.Param("id"))
|
|||
|
|
if id == "" {
|
|||
|
|
logger.Error(ctx, "Knowledge ID is empty")
|
|||
|
|
c.Error(errors.NewBadRequestError("Knowledge ID cannot be empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Keep a handler-level Editor check in addition to the route guard. The
|
|||
|
|
// original file is more sensitive than parsed-content reads and must not
|
|||
|
|
// be downloadable through a read-only organization share.
|
|||
|
|
_, effCtx, err := h.resolveKnowledgeAndValidateKBAccess(c, id, types.OrgRoleEditor)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
logger.Infof(ctx, "Retrieving knowledge file, ID: %s", secutils.SanitizeForLog(id))
|
|||
|
|
|
|||
|
|
file, filename, err := h.kgService.GetKnowledgeFile(effCtx, id)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to retrieve file").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
defer file.Close()
|
|||
|
|
|
|||
|
|
logger.Infof(
|
|||
|
|
ctx,
|
|||
|
|
"Knowledge file retrieved successfully, ID: %s, filename: %s",
|
|||
|
|
secutils.SanitizeForLog(id),
|
|||
|
|
secutils.SanitizeForLog(filename),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Set response headers for file download
|
|||
|
|
c.Header("Content-Description", "File Transfer")
|
|||
|
|
c.Header("Content-Transfer-Encoding", "binary")
|
|||
|
|
cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
|
|||
|
|
c.Header("Content-Disposition", cd)
|
|||
|
|
c.Header("Content-Type", "application/octet-stream")
|
|||
|
|
c.Header("Expires", "0")
|
|||
|
|
c.Header("Cache-Control", "must-revalidate")
|
|||
|
|
c.Header("Pragma", "public")
|
|||
|
|
|
|||
|
|
// Stream file content to response
|
|||
|
|
c.Stream(func(w io.Writer) bool {
|
|||
|
|
if _, err := io.Copy(w, file); err != nil {
|
|||
|
|
logger.Errorf(ctx, "Failed to send file: %v", err)
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
logger.Debug(ctx, "File sending completed")
|
|||
|
|
return false
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// mimeTypeByExt returns the MIME type for a given file extension.
|
|||
|
|
func mimeTypeByExt(filename string) string {
|
|||
|
|
ct, _ := secutils.SafeContentTypeByFilename(filename)
|
|||
|
|
return ct
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// PreviewKnowledgeFile godoc
|
|||
|
|
// @Summary 预览知识文件
|
|||
|
|
// @Description 返回知识条目关联的原始文件,Content-Type 根据文件类型设置,用于浏览器内嵌预览
|
|||
|
|
// @Tags 知识管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce application/pdf,image/jpeg,image/png,text/plain
|
|||
|
|
// @Param id path string true "知识ID"
|
|||
|
|
// @Success 200 {file} file "文件内容"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /knowledge/{id}/preview [get]
|
|||
|
|
func (h *KnowledgeHandler) PreviewKnowledgeFile(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
id := secutils.SanitizeForLog(c.Param("id"))
|
|||
|
|
if id == "" {
|
|||
|
|
c.Error(errors.NewBadRequestError("Knowledge ID cannot be empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_, effCtx, err := h.resolveKnowledgeAndValidateKBAccess(c, id, types.OrgRoleViewer)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
file, filename, err := h.kgService.GetKnowledgeFile(effCtx, id)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to retrieve file").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
defer file.Close()
|
|||
|
|
|
|||
|
|
contentType, inline := secutils.SafeContentTypeByFilename(filename)
|
|||
|
|
c.Header("Content-Type", contentType)
|
|||
|
|
c.Header("X-Content-Type-Options", "nosniff")
|
|||
|
|
disposition := "inline"
|
|||
|
|
if !inline {
|
|||
|
|
disposition = "attachment"
|
|||
|
|
}
|
|||
|
|
c.Header("Content-Disposition", mime.FormatMediaType(disposition, map[string]string{"filename": filename}))
|
|||
|
|
c.Header("Cache-Control", "private, max-age=3600")
|
|||
|
|
|
|||
|
|
c.Stream(func(w io.Writer) bool {
|
|||
|
|
if _, err := io.Copy(w, file); err != nil {
|
|||
|
|
logger.Errorf(ctx, "Failed to stream preview: %v", err)
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
return false
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetKnowledgeBatchRequest defines parameters for batch knowledge retrieval
|
|||
|
|
type GetKnowledgeBatchRequest struct {
|
|||
|
|
IDs []string `form:"ids" binding:"required"` // List of knowledge IDs
|
|||
|
|
KBID string `form:"kb_id"` // Optional: scope to this KB (validates access and uses effective tenant for shared KB)
|
|||
|
|
AgentID string `form:"agent_id"` // Optional: when using a shared agent, use agent's tenant for retrieval (validates shared agent access)
|
|||
|
|
AgentSourceTenantID uint64 `form:"agent_source_tenant_id"` // Optional source selector, verified against the share relation
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetKnowledgeBatch godoc
|
|||
|
|
// @Summary 批量获取知识
|
|||
|
|
// @Description 根据ID列表批量获取知识条目。可选 kb_id:指定时按该知识库校验权限并用于共享知识库的空间解析;可选 agent_id:使用共享智能体时传此参数,后端按智能体所属空间查询(用于刷新后恢复共享知识库下的文件)
|
|||
|
|
// @Tags 知识管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param ids query []string true "知识ID列表"
|
|||
|
|
// @Param kb_id query string false "可选,知识库ID(用于共享知识库时指定范围)"
|
|||
|
|
// @Param agent_id query string false "可选,共享智能体ID(用于按智能体空间批量拉取文件详情)"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "知识列表"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /knowledge/batch [get]
|
|||
|
|
func (h *KnowledgeHandler) GetKnowledgeBatch(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
tenantID, ok := c.Get(types.TenantIDContextKey.String())
|
|||
|
|
if !ok {
|
|||
|
|
logger.Error(ctx, "Failed to get tenant ID")
|
|||
|
|
c.Error(errors.NewUnauthorizedError("Unauthorized"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
effectiveTenantID := tenantID.(uint64)
|
|||
|
|
|
|||
|
|
var req GetKnowledgeBatchRequest
|
|||
|
|
if err := c.ShouldBindQuery(&req); err != nil {
|
|||
|
|
logger.Error(ctx, "Failed to parse request parameters", err)
|
|||
|
|
c.Error(errors.NewBadRequestError("Invalid request parameters").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if _, parseErr := types.ParseAgentSourceTenantID(c.Query(types.AgentSourceTenantIDParam)); parseErr != nil {
|
|||
|
|
c.Error(errors.NewBadRequestError(parseErr.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// agentAllowedKBIDs restricts results to the agent's configured KB scope.
|
|||
|
|
// nil = no agent restriction; empty slice = agent has no KB access (none mode).
|
|||
|
|
var agentAllowedKBIDs []string
|
|||
|
|
|
|||
|
|
// Optional agent_id: when using shared agent, resolve agent and use its tenant for batch retrieval (so shared KB files can be loaded after refresh)
|
|||
|
|
if agentID := secutils.SanitizeForLog(req.AgentID); agentID != "" && h.agentShareService != nil {
|
|||
|
|
userIDVal, ok := c.Get(types.UserIDContextKey.String())
|
|||
|
|
if !ok {
|
|||
|
|
c.Error(errors.NewUnauthorizedError("Unauthorized"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
userID, _ := userIDVal.(string)
|
|||
|
|
currentTenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|||
|
|
if currentTenantID == 0 {
|
|||
|
|
c.Error(errors.NewUnauthorizedError("Unauthorized"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
callerTenantRole := types.TenantRoleFromContext(ctx)
|
|||
|
|
agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID, req.AgentSourceTenantID)
|
|||
|
|
if err != nil || agent == nil {
|
|||
|
|
logger.Warnf(ctx, "GetKnowledgeBatch: invalid or inaccessible shared agent %s: %v", agentID, err)
|
|||
|
|
c.Error(errors.NewForbiddenError("Invalid or inaccessible shared agent").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
_ = userID
|
|||
|
|
effectiveTenantID = agent.TenantID
|
|||
|
|
agentAllowedKBIDs = resolveAgentAllowedKBIDs(agent)
|
|||
|
|
|
|||
|
|
if agentAllowedKBIDs != nil && len(agentAllowedKBIDs) == 0 {
|
|||
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": []*types.Knowledge{}})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
logger.Infof(ctx, "Batch retrieving knowledge with agent_id, effective tenant ID: %d, IDs count: %d, allowed KBs: %v",
|
|||
|
|
effectiveTenantID, len(req.IDs), agentAllowedKBIDs)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var knowledges []*types.Knowledge
|
|||
|
|
var err error
|
|||
|
|
|
|||
|
|
// scopeKBID tracks the single KB the results must belong to (set by explicit kb_id).
|
|||
|
|
var scopeKBID string
|
|||
|
|
|
|||
|
|
// Optional kb_id: validate KB access and use effective tenant for shared KB
|
|||
|
|
if kbID := secutils.SanitizeForLog(req.KBID); kbID != "" {
|
|||
|
|
_, _, effID, _, err := h.validateKnowledgeBaseAccessWithKBID(c, kbID)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if agentAllowedKBIDs != nil && !sliceContains(agentAllowedKBIDs, kbID) {
|
|||
|
|
c.Error(errors.NewForbiddenError("Knowledge base not accessible through this agent"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
scopeKBID = kbID
|
|||
|
|
effectiveTenantID = effID
|
|||
|
|
ctx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID)
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Batch retrieving knowledge with kb_id, effective tenant ID: %d, IDs count: %d",
|
|||
|
|
effectiveTenantID, len(req.IDs))
|
|||
|
|
|
|||
|
|
knowledges, err = h.kgService.GetKnowledgeBatch(ctx, effectiveTenantID, req.IDs)
|
|||
|
|
} else {
|
|||
|
|
// No kb_id: use GetKnowledgeBatchWithSharedAccess (or effectiveTenantID may already be set by agent_id for shared agent)
|
|||
|
|
logger.Infof(ctx, "Batch retrieving knowledge without kb_id, effective tenant ID: %d, IDs count: %d",
|
|||
|
|
effectiveTenantID, len(req.IDs))
|
|||
|
|
|
|||
|
|
knowledges, err = h.kgService.GetKnowledgeBatchWithSharedAccess(ctx, effectiveTenantID, req.IDs)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Build the effective allowed-KB set from explicit kb_id, shared agent
|
|||
|
|
// scope, and per-API-key KB restrictions.
|
|||
|
|
var allowedKBSet map[string]bool
|
|||
|
|
if scopeKBID != "" {
|
|||
|
|
allowedKBSet = map[string]bool{scopeKBID: true}
|
|||
|
|
} else if agentAllowedKBIDs != nil {
|
|||
|
|
allowedKBSet = make(map[string]bool, len(agentAllowedKBIDs))
|
|||
|
|
for _, id := range agentAllowedKBIDs {
|
|||
|
|
allowedKBSet[id] = true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if apiKeySet := tenantAPIKeyAllowedKBSet(ctx); apiKeySet != nil {
|
|||
|
|
allowedKBSet = intersectKBAllowSet(allowedKBSet, apiKeySet)
|
|||
|
|
}
|
|||
|
|
if allowedKBSet != nil {
|
|||
|
|
knowledges = filterKnowledgesByKBAllowSet(knowledges, allowedKBSet)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to retrieve knowledge list").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Batch knowledge retrieval successful, requested count: %d, returned count: %d",
|
|||
|
|
len(req.IDs), len(knowledges))
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": knowledges,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// UpdateKnowledgeRequest defines the partial-update body for PUT /knowledge/:id.
|
|||
|
|
// Omitted fields are left unchanged; an explicit empty description clears the summary.
|
|||
|
|
type UpdateKnowledgeRequest struct {
|
|||
|
|
Title *string `json:"title"`
|
|||
|
|
Description *string `json:"description"`
|
|||
|
|
CustomMetadata json.RawMessage `json:"custom_metadata"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// UpdateKnowledge godoc
|
|||
|
|
// @Summary 更新知识
|
|||
|
|
// @Description 部分更新知识条目(标题/描述/自定义元数据);未传字段保持不变,显式传空 description 可清空摘要
|
|||
|
|
// @Tags 知识管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path string true "知识ID"
|
|||
|
|
// @Param request body UpdateKnowledgeRequest true "更新字段(均可选)"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "更新成功"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /knowledge/{id} [put]
|
|||
|
|
func (h *KnowledgeHandler) UpdateKnowledge(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
id := secutils.SanitizeForLog(c.Param("id"))
|
|||
|
|
if id == "" {
|
|||
|
|
logger.Error(ctx, "Knowledge ID is empty")
|
|||
|
|
c.Error(errors.NewBadRequestError("Knowledge ID cannot be empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_, effCtx, err := h.resolveKnowledgeAndValidateKBAccess(c, id, types.OrgRoleEditor)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var req UpdateKnowledgeRequest
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
logger.Error(ctx, "Failed to parse request parameters", err)
|
|||
|
|
c.Error(errors.NewBadRequestError(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
knowledge := types.Knowledge{ID: id, CustomMetadata: types.JSON(req.CustomMetadata)}
|
|||
|
|
if req.Title != nil {
|
|||
|
|
knowledge.Title = *req.Title
|
|||
|
|
}
|
|||
|
|
if req.Description != nil {
|
|||
|
|
knowledge.Description = *req.Description
|
|||
|
|
knowledge.DescriptionSpecified = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := h.kgService.UpdateKnowledge(effCtx, &knowledge); err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
updated, getErr := h.kgService.GetKnowledgeByID(effCtx, id)
|
|||
|
|
if getErr != nil {
|
|||
|
|
logger.Warnf(ctx, "Knowledge updated but failed to reload status for %s: %v", id, getErr)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Knowledge updated successfully, knowledge ID: %s", id)
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"message": "Knowledge updated successfully",
|
|||
|
|
"data": updated,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// RegenerateKnowledgeSummary refreshes a stale summary after chunk or metadata edits.
|
|||
|
|
func (h *KnowledgeHandler) RegenerateKnowledgeSummary(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
id := secutils.SanitizeForLog(c.Param("id"))
|
|||
|
|
if id == "" {
|
|||
|
|
c.Error(errors.NewBadRequestError("Knowledge ID cannot be empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
_, effCtx, err := h.resolveKnowledgeAndValidateKBAccess(c, id, types.OrgRoleEditor)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
knowledge, err := h.kgService.GetKnowledgeByID(effCtx, id)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if knowledge.SummaryStatus == "" || knowledge.SummaryStatus == types.SummaryStatusNone {
|
|||
|
|
knowledge, err = h.kgService.RegenerateKnowledgeSummary(effCtx, id)
|
|||
|
|
} else {
|
|||
|
|
err = h.kgService.RequestKnowledgeSummaryRefresh(effCtx, id)
|
|||
|
|
if err == nil {
|
|||
|
|
knowledge, err = h.kgService.GetKnowledgeByID(effCtx, id)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewBadRequestError(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": knowledge})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// UpdateManualKnowledge godoc
|
|||
|
|
// @Summary 更新手工知识
|
|||
|
|
// @Description 更新手工录入的Markdown知识内容
|
|||
|
|
// @Tags 知识管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path string true "知识ID"
|
|||
|
|
// @Param request body types.ManualKnowledgePayload true "手工知识内容"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "更新后的知识"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /knowledge/manual/{id} [put]
|
|||
|
|
func (h *KnowledgeHandler) UpdateManualKnowledge(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
logger.Info(ctx, "Start updating manual knowledge")
|
|||
|
|
|
|||
|
|
id := secutils.SanitizeForLog(c.Param("id"))
|
|||
|
|
if id == "" {
|
|||
|
|
logger.Error(ctx, "Knowledge ID is empty")
|
|||
|
|
c.Error(errors.NewBadRequestError("Knowledge ID cannot be empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_, effCtx, err := h.resolveKnowledgeAndValidateKBAccess(c, id, types.OrgRoleEditor)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var req types.ManualKnowledgePayload
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
logger.Error(ctx, "Failed to parse manual knowledge update request", err)
|
|||
|
|
c.Error(errors.NewBadRequestError(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
knowledge, err := h.kgService.UpdateManualKnowledge(effCtx, id, &req)
|
|||
|
|
if err != nil {
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
c.Error(appErr)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|||
|
|
"knowledge_id": id,
|
|||
|
|
})
|
|||
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Manual knowledge updated successfully, knowledge ID: %s", id)
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": knowledge,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ReparseKnowledge godoc
|
|||
|
|
// @Summary 重新解析知识
|
|||
|
|
// @Description 删除知识中现有的文档内容并重新解析,使用异步任务方式处理
|
|||
|
|
// @Tags 知识管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path string true "知识ID"
|
|||
|
|
// @Param body body object false "可选的处理配置覆盖:{\"process_config\": KnowledgeProcessOverrides}"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "重新解析任务已提交"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Failure 403 {object} errors.AppError "权限不足"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /knowledge/{id}/reparse [post]
|
|||
|
|
func (h *KnowledgeHandler) ReparseKnowledge(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
logger.Info(ctx, "Start re-parsing knowledge")
|
|||
|
|
|
|||
|
|
id := secutils.SanitizeForLog(c.Param("id"))
|
|||
|
|
if id == "" {
|
|||
|
|
logger.Error(ctx, "Knowledge ID is empty")
|
|||
|
|
c.Error(errors.NewBadRequestError("Knowledge ID cannot be empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Validate KB access with editor permission (reparse requires write access)
|
|||
|
|
_, effCtx, err := h.resolveKnowledgeAndValidateKBAccess(c, id, types.OrgRoleEditor)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Optional per-reparse parse config override. Empty body keeps the
|
|||
|
|
// overrides stored at upload time.
|
|||
|
|
var processOverrides *types.KnowledgeProcessOverrides
|
|||
|
|
if c.Request.ContentLength != 0 {
|
|||
|
|
var req struct {
|
|||
|
|
ProcessConfig *types.KnowledgeProcessOverrides `json:"process_config"`
|
|||
|
|
}
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
logger.Error(ctx, "Failed to parse reparse request body", err)
|
|||
|
|
c.Error(errors.NewBadRequestError("Invalid reparse request body").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
processOverrides = req.ProcessConfig
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Call service to reparse knowledge
|
|||
|
|
knowledge, err := h.kgService.ReparseKnowledge(effCtx, id, processOverrides)
|
|||
|
|
if err != nil {
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
c.Error(appErr)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|||
|
|
"knowledge_id": id,
|
|||
|
|
})
|
|||
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Knowledge reparse task submitted successfully, knowledge ID: %s", id)
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"message": "Knowledge reparse task submitted",
|
|||
|
|
"data": knowledge,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CancelKnowledgeParse godoc
|
|||
|
|
// @Summary 取消知识解析
|
|||
|
|
// @Description 取消进行中的知识解析任务。当前已写入的 chunk / 索引保留,可通过 reparse 接口重新触发解析。已完成 / 已失败 / 删除中的知识不支持取消。
|
|||
|
|
// @Tags 知识管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path string true "知识ID"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "取消已提交"
|
|||
|
|
// @Failure 400 {object} errors.AppError "状态不支持取消"
|
|||
|
|
// @Failure 403 {object} errors.AppError "权限不足"
|
|||
|
|
// @Failure 404 {object} errors.AppError "知识不存在"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /knowledge/{id}/cancel-parse [post]
|
|||
|
|
func (h *KnowledgeHandler) CancelKnowledgeParse(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
logger.Info(ctx, "Start cancelling knowledge parse")
|
|||
|
|
|
|||
|
|
id := secutils.SanitizeForLog(c.Param("id"))
|
|||
|
|
if id == "" {
|
|||
|
|
logger.Error(ctx, "Knowledge ID is empty")
|
|||
|
|
c.Error(errors.NewBadRequestError("Knowledge ID cannot be empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Editor permission — same gate as ReparseKnowledge / DeleteKnowledge.
|
|||
|
|
_, effCtx, err := h.resolveKnowledgeAndValidateKBAccess(c, id, types.OrgRoleEditor)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
knowledge, err := h.kgService.CancelKnowledgeParse(effCtx, id)
|
|||
|
|
if err != nil {
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
c.Error(appErr)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|||
|
|
"knowledge_id": id,
|
|||
|
|
})
|
|||
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Knowledge parse cancelled successfully, knowledge ID: %s", id)
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"message": "Knowledge parse cancelled",
|
|||
|
|
"data": knowledge,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type knowledgeTagBatchRequest struct {
|
|||
|
|
Updates map[string][]string `json:"updates" binding:"required,min=1"`
|
|||
|
|
KBID string `json:"kb_id"` // Optional: scope to this KB (validates editor access and uses effective tenant for shared KB)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// UpdateKnowledgeTagBatch godoc
|
|||
|
|
// @Summary 批量更新知识标签
|
|||
|
|
// @Description 批量更新知识条目的标签。可选 kb_id:指定时按该知识库校验编辑权限并用于共享知识库的空间解析
|
|||
|
|
// @Tags 知识管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param request body object true "标签更新请求(updates 必填,kb_id 可选)"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "更新成功"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /knowledge/tags [put]
|
|||
|
|
func (h *KnowledgeHandler) UpdateKnowledgeTagBatch(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
// Ensure tenant ID is in context (service reads it; may be missing if request context was not set by auth)
|
|||
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|||
|
|
if tenantID == 0 {
|
|||
|
|
c.Error(errors.NewUnauthorizedError("Unauthorized"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
ctx = context.WithValue(ctx, types.TenantIDContextKey, tenantID)
|
|||
|
|
|
|||
|
|
var req knowledgeTagBatchRequest
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
logger.Error(ctx, "Failed to parse knowledge tag batch request", err)
|
|||
|
|
c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
// Resolve effective tenant and the authorized KB scope.
|
|||
|
|
var authorizedKBID string
|
|||
|
|
if kbID := secutils.SanitizeForLog(req.KBID); kbID != "" {
|
|||
|
|
_, _, effID, permission, err := h.validateKnowledgeBaseAccessWithKBID(c, kbID)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if permission != types.OrgRoleAdmin && permission != types.OrgRoleEditor {
|
|||
|
|
c.Error(errors.NewForbiddenError("No permission to update knowledge tags"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
authorizedKBID = kbID
|
|||
|
|
ctx = context.WithValue(ctx, types.TenantIDContextKey, effID)
|
|||
|
|
} else if len(req.Updates) > 0 {
|
|||
|
|
// No kb_id: infer from first knowledge ID so shared-KB updates work without client sending kb_id
|
|||
|
|
var firstKnowledgeID string
|
|||
|
|
for id := range req.Updates {
|
|||
|
|
firstKnowledgeID = id
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
if firstKnowledgeID != "" {
|
|||
|
|
knowledge, effCtx, err := h.resolveKnowledgeAndValidateKBAccess(c, firstKnowledgeID, types.OrgRoleEditor)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
authorizedKBID = knowledge.KnowledgeBaseID
|
|||
|
|
ctx = effCtx
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if err := h.requireKBOwnershipOrAdmin(c, authorizedKBID); err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if err := h.kgService.UpdateKnowledgeTagBatch(ctx, authorizedKBID, req.Updates); err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// UpdateImageInfo godoc
|
|||
|
|
// @Summary 更新图像信息
|
|||
|
|
// @Description 更新知识分块的图像信息
|
|||
|
|
// @Tags 知识管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path string true "知识ID"
|
|||
|
|
// @Param chunk_id path string true "分块ID"
|
|||
|
|
// @Param request body object{image_info=string} true "图像信息"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "更新成功"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /knowledge/image/{id}/{chunk_id} [put]
|
|||
|
|
func (h *KnowledgeHandler) UpdateImageInfo(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
logger.Info(ctx, "Start updating image info")
|
|||
|
|
|
|||
|
|
id := secutils.SanitizeForLog(c.Param("id"))
|
|||
|
|
if id == "" {
|
|||
|
|
logger.Error(ctx, "Knowledge ID is empty")
|
|||
|
|
c.Error(errors.NewBadRequestError("Knowledge ID cannot be empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
chunkID := secutils.SanitizeForLog(c.Param("chunk_id"))
|
|||
|
|
if chunkID == "" {
|
|||
|
|
logger.Error(ctx, "Chunk ID is empty")
|
|||
|
|
c.Error(errors.NewBadRequestError("Chunk ID cannot be empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_, effCtx, err := h.resolveKnowledgeAndValidateKBAccess(c, id, types.OrgRoleEditor)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var request struct {
|
|||
|
|
ImageInfo string `json:"image_info"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := c.ShouldBindJSON(&request); err != nil {
|
|||
|
|
logger.Error(ctx, "Failed to parse request parameters", err)
|
|||
|
|
c.Error(errors.NewBadRequestError(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Updating knowledge chunk, knowledge ID: %s, chunk ID: %s", id, chunkID)
|
|||
|
|
err = h.kgService.UpdateImageInfo(effCtx, id, chunkID, secutils.SanitizeForLog(request.ImageInfo))
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Knowledge chunk updated successfully, knowledge ID: %s, chunk ID: %s", id, chunkID)
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"message": "Knowledge chunk image updated successfully",
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SearchKnowledge godoc
|
|||
|
|
// @Summary Search knowledge
|
|||
|
|
// @Description Search knowledge files by keyword. Pass recent=true without a keyword to browse recent files. When agent_id is set (shared agent), scope is the agent's configured knowledge bases.
|
|||
|
|
// @Tags Knowledge
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param keyword query string false "Keyword to search"
|
|||
|
|
// @Param offset query int false "Offset for pagination (minimum 0)" minimum(0)
|
|||
|
|
// @Param limit query int false "Limit for pagination (default 20, maximum 100)" minimum(1) maximum(100)
|
|||
|
|
// @Param file_types query string false "Comma-separated file extensions to filter (e.g., csv,xlsx)"
|
|||
|
|
// @Param agent_id query string false "Shared agent ID (search within agent's KB scope)"
|
|||
|
|
// @Param recent query bool false "Return recent files when keyword is empty"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "Search results"
|
|||
|
|
// @Failure 400 {object} errors.AppError "Invalid request"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /knowledge/search [get]
|
|||
|
|
func (h *KnowledgeHandler) SearchKnowledge(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
if userID, ok := c.Get(types.UserIDContextKey.String()); ok {
|
|||
|
|
ctx = context.WithValue(ctx, types.UserIDContextKey, userID)
|
|||
|
|
}
|
|||
|
|
// Accept both ?keyword= (legacy / upstream name) and ?query= (what most
|
|||
|
|
// MCP / agent integrations send). Empty input is only valid for an explicit
|
|||
|
|
// recent-file browse request; ordinary callers still get a clear 400 instead
|
|||
|
|
// of silently receiving the same newest cards for every missing query.
|
|||
|
|
keyword := c.Query("keyword")
|
|||
|
|
if keyword == "" {
|
|||
|
|
keyword = c.Query("query")
|
|||
|
|
}
|
|||
|
|
recent, _ := strconv.ParseBool(c.DefaultQuery("recent", "false"))
|
|||
|
|
if strings.TrimSpace(keyword) == "" && !recent {
|
|||
|
|
c.Error(errors.NewBadRequestError("missing search keyword: pass ?keyword=... or ?query=..."))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
keyword = strings.TrimSpace(keyword)
|
|||
|
|
offset, limit, ok := parseOffsetPagination(c)
|
|||
|
|
if !ok {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var fileTypes []string
|
|||
|
|
if fileTypesStr := c.Query("file_types"); fileTypesStr != "" {
|
|||
|
|
for _, ft := range strings.Split(fileTypesStr, ",") {
|
|||
|
|
ft = strings.TrimSpace(ft)
|
|||
|
|
if ft != "" {
|
|||
|
|
fileTypes = append(fileTypes, ft)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
agentID := c.Query("agent_id")
|
|||
|
|
if agentID != "" {
|
|||
|
|
userIDVal, ok := c.Get(types.UserIDContextKey.String())
|
|||
|
|
if !ok {
|
|||
|
|
c.Error(errors.NewUnauthorizedError("user ID not found"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
_ = userIDVal
|
|||
|
|
currentTenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|||
|
|
if currentTenantID == 0 {
|
|||
|
|
c.Error(errors.NewUnauthorizedError("workspace ID not found"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
callerTenantRole := types.TenantRoleFromContext(ctx)
|
|||
|
|
requestedSourceTenantID, parseErr := types.ParseAgentSourceTenantID(c.Query(types.AgentSourceTenantIDParam))
|
|||
|
|
if parseErr != nil {
|
|||
|
|
c.Error(errors.NewBadRequestError(parseErr.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID, requestedSourceTenantID)
|
|||
|
|
if err != nil {
|
|||
|
|
if goerrors.Is(err, service.ErrAgentShareNotFound) || goerrors.Is(err, service.ErrAgentSharePermission) || goerrors.Is(err, service.ErrAgentNotFoundForShare) {
|
|||
|
|
c.Error(errors.NewForbiddenError("no permission for this shared agent"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to verify shared agent access").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
sourceTenantID := agent.TenantID
|
|||
|
|
mode := agent.Config.KBSelectionMode
|
|||
|
|
if mode == "none" {
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": []interface{}{},
|
|||
|
|
"has_more": false,
|
|||
|
|
"total": 0,
|
|||
|
|
})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
var scopes []types.KnowledgeSearchScope
|
|||
|
|
if mode == "selected" && len(agent.Config.KnowledgeBases) > 0 {
|
|||
|
|
for _, kbID := range agent.Config.KnowledgeBases {
|
|||
|
|
if kbID != "" {
|
|||
|
|
scopes = append(scopes, types.KnowledgeSearchScope{TenantID: sourceTenantID, KBID: kbID})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if len(scopes) == 0 {
|
|||
|
|
kbs, err := h.kbService.ListKnowledgeBasesByTenantID(ctx, sourceTenantID)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to list knowledge bases").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
// `all` mode: authoritative server-side capability filter. Mirrors the
|
|||
|
|
// logic in ListKnowledgeBases so @file search, KB listing, and runtime
|
|||
|
|
// all agree on what "mode=all" actually means for this agent. The
|
|||
|
|
// filter is agent-mode aware so quick-answer (RAG-only) skips
|
|||
|
|
// wiki-only KBs even though it has no `allowed_tools`.
|
|||
|
|
filter := tools.DeriveKBFilterForAgent(agent.Config.AgentMode, agent.Config.AllowedTools)
|
|||
|
|
removed := 0
|
|||
|
|
for _, kb := range kbs {
|
|||
|
|
if kb == nil || kb.Type != types.KnowledgeBaseTypeDocument {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if !filter.IsEmpty() && !tools.KBSatisfiesAgentRequirements(kb.Capabilities(), agent.Config.AgentMode, agent.Config.AllowedTools) {
|
|||
|
|
removed++
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
scopes = append(scopes, types.KnowledgeSearchScope{TenantID: sourceTenantID, KBID: kb.ID})
|
|||
|
|
}
|
|||
|
|
if removed > 0 {
|
|||
|
|
logger.Infof(ctx,
|
|||
|
|
"SearchKnowledge(agent=%s, mode=all): capability filter removed %d KBs",
|
|||
|
|
agentID, removed)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
scopes = filterKnowledgeSearchScopesForAPIKey(ctx, scopes)
|
|||
|
|
if len(scopes) == 0 {
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": []interface{}{},
|
|||
|
|
"has_more": false,
|
|||
|
|
"total": 0,
|
|||
|
|
})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
knowledges, hasMore, total, err := h.kgService.SearchKnowledgeForScopes(ctx, scopes, keyword, offset, limit, fileTypes)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to search knowledge").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": knowledges,
|
|||
|
|
"has_more": hasMore,
|
|||
|
|
"total": total,
|
|||
|
|
})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if scopes, restricted := tenantAPIKeySearchScopes(ctx); restricted {
|
|||
|
|
if len(scopes) == 0 {
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": []interface{}{},
|
|||
|
|
"has_more": false,
|
|||
|
|
"total": 0,
|
|||
|
|
})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
knowledges, hasMore, total, err := h.kgService.SearchKnowledgeForScopes(ctx, scopes, keyword, offset, limit, fileTypes)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to search knowledge").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": knowledges,
|
|||
|
|
"has_more": hasMore,
|
|||
|
|
"total": total,
|
|||
|
|
})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Default: own + shared KBs
|
|||
|
|
knowledges, hasMore, total, err := h.kgService.SearchKnowledge(ctx, keyword, offset, limit, fileTypes)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to search knowledge").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": knowledges,
|
|||
|
|
"has_more": hasMore,
|
|||
|
|
"total": total,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MoveKnowledgeRequest defines the request for moving knowledge items
|
|||
|
|
type MoveKnowledgeRequest struct {
|
|||
|
|
KnowledgeIDs []string `json:"knowledge_ids" binding:"required,min=1"`
|
|||
|
|
SourceKBID string `json:"source_kb_id" binding:"required"`
|
|||
|
|
TargetKBID string `json:"target_kb_id" binding:"required"`
|
|||
|
|
Mode string `json:"mode" binding:"required,oneof=reuse_vectors reparse"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MoveKnowledgeResponse defines the response for move knowledge
|
|||
|
|
type MoveKnowledgeResponse struct {
|
|||
|
|
TaskID string `json:"task_id"`
|
|||
|
|
SourceKBID string `json:"source_kb_id"`
|
|||
|
|
TargetKBID string `json:"target_kb_id"`
|
|||
|
|
KnowledgeCount int `json:"knowledge_count"`
|
|||
|
|
Message string `json:"message"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MoveKnowledge moves knowledge items from one knowledge base to another (async task).
|
|||
|
|
//
|
|||
|
|
// MoveKnowledge godoc
|
|||
|
|
// @Summary 移动知识到其他知识库
|
|||
|
|
// @Description 将一条或多条知识从源知识库移动到目标知识库(异步),返回任务 ID 用于查询进度
|
|||
|
|
// @Tags 知识
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param request body handler.MoveKnowledgeRequest true "{source_kb_id, target_kb_id, knowledge_ids}"
|
|||
|
|
// @Success 200 {object} handler.MoveKnowledgeResponse "任务信息"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /knowledge/move [post]
|
|||
|
|
func (h *KnowledgeHandler) MoveKnowledge(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
var req MoveKnowledgeRequest
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
logger.Error(ctx, "MoveKnowledge: failed to parse request", err)
|
|||
|
|
c.Error(errors.NewBadRequestError("Invalid request parameters: " + err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Validate source != target
|
|||
|
|
if req.SourceKBID == req.TargetKBID {
|
|||
|
|
c.Error(errors.NewBadRequestError("Source and target knowledge base cannot be the same"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
tenantID, exists := c.Get(types.TenantIDContextKey.String())
|
|||
|
|
if !exists {
|
|||
|
|
c.Error(errors.NewUnauthorizedError("Unauthorized"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if err := requireTenantAPIKeyKnowledgeBases(ctx, req.SourceKBID, req.TargetKBID); err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Validate source KB
|
|||
|
|
sourceKB, err := h.kbService.GetKnowledgeBaseByID(ctx, req.SourceKBID)
|
|||
|
|
if err != nil {
|
|||
|
|
if goerrors.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 source knowledge base"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if err := h.requireKBOwnershipOrAdmin(c, req.SourceKBID); err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Validate target KB
|
|||
|
|
targetKB, err := h.kbService.GetKnowledgeBaseByID(ctx, req.TargetKBID)
|
|||
|
|
if err != nil {
|
|||
|
|
if goerrors.Is(err, repository.ErrKnowledgeBaseNotFound) {
|
|||
|
|
c.Error(errors.NewNotFoundError("Target knowledge base not found"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if targetKB.TenantID != tenantID.(uint64) {
|
|||
|
|
c.Error(errors.NewForbiddenError("No permission to access target knowledge base"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if err := h.requireKBOwnershipOrAdmin(c, req.TargetKBID); err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Validate type match
|
|||
|
|
if sourceKB.Type != targetKB.Type {
|
|||
|
|
c.Error(errors.NewBadRequestError("Source and target knowledge bases must be the same type"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Validate embedding model match
|
|||
|
|
if sourceKB.EmbeddingModelID != targetKB.EmbeddingModelID {
|
|||
|
|
c.Error(errors.NewBadRequestError("Source and target must use the same embedding model"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// reuse_vectors copies index entries directly between KBs, which only works
|
|||
|
|
// inside the same VectorStore backend. A cross-store reuse_vectors move would
|
|||
|
|
// route CopyIndices through the SOURCE store and then delete the source
|
|||
|
|
// indices, corrupting the vector data. Reject it and point the caller at
|
|||
|
|
// reparse mode, which re-indexes into the target store safely.
|
|||
|
|
if req.Mode == "reuse_vectors" && !sourceKB.SharesStoreWith(targetKB) {
|
|||
|
|
c.Error(errors.NewBadRequestError(
|
|||
|
|
"reuse_vectors move across different vector stores is not supported; " +
|
|||
|
|
"use reparse mode to move into a different store"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Validate all knowledge IDs belong to source KB and are in completed status
|
|||
|
|
for _, kID := range req.KnowledgeIDs {
|
|||
|
|
knowledge, err := h.kgService.GetKnowledgeByID(ctx, kID)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(errors.NewBadRequestError(fmt.Sprintf("Knowledge item %s not found", kID)))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if knowledge.KnowledgeBaseID != req.SourceKBID {
|
|||
|
|
c.Error(errors.NewBadRequestError(fmt.Sprintf("Knowledge item %s does not belong to the source knowledge base", kID)))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if knowledge.ParseStatus != types.ParseStatusCompleted {
|
|||
|
|
c.Error(errors.NewBadRequestError(fmt.Sprintf("Knowledge item %s is not in completed status (current: %s)", kID, knowledge.ParseStatus)))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Generate task ID
|
|||
|
|
taskID := utils.GenerateTaskID("kg_move", tenantID.(uint64), req.SourceKBID)
|
|||
|
|
|
|||
|
|
// Create move payload
|
|||
|
|
payload := types.KnowledgeMovePayload{
|
|||
|
|
TenantID: tenantID.(uint64),
|
|||
|
|
TaskID: taskID,
|
|||
|
|
KnowledgeIDs: req.KnowledgeIDs,
|
|||
|
|
SourceKBID: req.SourceKBID,
|
|||
|
|
TargetKBID: req.TargetKBID,
|
|||
|
|
Mode: req.Mode,
|
|||
|
|
Initiator: types.TaskInitiatorFromContext(ctx),
|
|||
|
|
}
|
|||
|
|
langfuse.InjectTracing(ctx, &payload)
|
|||
|
|
|
|||
|
|
payloadBytes, err := json.Marshal(payload)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Errorf(ctx, "MoveKnowledge: failed to marshal payload: %v", err)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to create task"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Enqueue move task
|
|||
|
|
task := asynq.NewTask(types.TypeKnowledgeMove, payloadBytes,
|
|||
|
|
asynq.TaskID(taskID), asynq.Queue(types.QueueMaintenance),
|
|||
|
|
asynq.MaxRetry(3), asynq.Timeout(2*time.Hour))
|
|||
|
|
info, err := h.asynqClient.Enqueue(task)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Errorf(ctx, "MoveKnowledge: failed to enqueue task: %v", err)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to enqueue task"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "MoveKnowledge: task enqueued: %s, asynq_id: %s, source: %s, target: %s, count: %d",
|
|||
|
|
taskID, info.ID, secutils.SanitizeForLog(req.SourceKBID), secutils.SanitizeForLog(req.TargetKBID), len(req.KnowledgeIDs))
|
|||
|
|
|
|||
|
|
// Save initial progress
|
|||
|
|
initialProgress := &types.KnowledgeMoveProgress{
|
|||
|
|
TaskID: taskID,
|
|||
|
|
SourceKBID: req.SourceKBID,
|
|||
|
|
TargetKBID: req.TargetKBID,
|
|||
|
|
Status: types.KBCloneStatusPending,
|
|||
|
|
Total: len(req.KnowledgeIDs),
|
|||
|
|
Progress: 0,
|
|||
|
|
Message: "Task queued, waiting to start...",
|
|||
|
|
CreatedAt: time.Now().Unix(),
|
|||
|
|
UpdatedAt: time.Now().Unix(),
|
|||
|
|
}
|
|||
|
|
if err := h.kgService.SaveKnowledgeMoveProgress(ctx, initialProgress); err != nil {
|
|||
|
|
logger.Warnf(ctx, "MoveKnowledge: failed to save initial progress: %v", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": MoveKnowledgeResponse{
|
|||
|
|
TaskID: taskID,
|
|||
|
|
SourceKBID: req.SourceKBID,
|
|||
|
|
TargetKBID: req.TargetKBID,
|
|||
|
|
KnowledgeCount: len(req.KnowledgeIDs),
|
|||
|
|
Message: "Knowledge move task started",
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetKnowledgeMoveProgress retrieves the progress of a knowledge move task.
|
|||
|
|
//
|
|||
|
|
// GetKnowledgeMoveProgress godoc
|
|||
|
|
// @Summary 获取知识移动进度
|
|||
|
|
// @Description 按任务 ID 查询移动进度
|
|||
|
|
// @Tags 知识
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param task_id path string true "移动任务 ID"
|
|||
|
|
// @Success 200 {object} types.KnowledgeMoveProgress "进度信息"
|
|||
|
|
// @Failure 404 {object} errors.AppError "任务不存在"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /knowledge/move/progress/{task_id} [get]
|
|||
|
|
func (h *KnowledgeHandler) GetKnowledgeMoveProgress(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
taskID := c.Param("task_id")
|
|||
|
|
if taskID == "" {
|
|||
|
|
c.Error(errors.NewBadRequestError("Task ID cannot be empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if err := requireTaskProgressTenant(ctx, taskID); err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
progress, err := h.kgService.GetKnowledgeMoveProgress(ctx, taskID)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": progress,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// resolveAgentAllowedKBIDs returns the set of knowledge base IDs that the
|
|||
|
|
// shared agent is allowed to access based on its KBSelectionMode config.
|
|||
|
|
// Returns nil when no restriction applies ("all" mode), or a concrete slice
|
|||
|
|
// (possibly empty for "none" mode) when the results must be filtered.
|
|||
|
|
func resolveAgentAllowedKBIDs(agent *types.CustomAgent) []string {
|
|||
|
|
switch agent.Config.KBSelectionMode {
|
|||
|
|
case "all":
|
|||
|
|
return nil
|
|||
|
|
case "none":
|
|||
|
|
return []string{}
|
|||
|
|
case "selected":
|
|||
|
|
return agent.Config.KnowledgeBases
|
|||
|
|
default:
|
|||
|
|
if len(agent.Config.KnowledgeBases) > 0 {
|
|||
|
|
return agent.Config.KnowledgeBases
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// parseCommaSeparatedTagIDs splits a comma-separated string of tag IDs and
|
|||
|
|
// filters out empty strings and the "__untagged__" sentinel value.
|
|||
|
|
func parseCommaSeparatedTagIDs(raw string) []string {
|
|||
|
|
if raw == "" {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
parts := strings.Split(raw, ",")
|
|||
|
|
result := make([]string, 0, len(parts))
|
|||
|
|
for _, p := range parts {
|
|||
|
|
p = strings.TrimSpace(p)
|
|||
|
|
if p == "" || p == "__untagged__" {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
result = append(result, p)
|
|||
|
|
}
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// parseFilterTime parses a query-string timestamp accepted by knowledge list
|
|||
|
|
// filters. It supports RFC3339, RFC3339 with milliseconds, and the date-only
|
|||
|
|
// "2006-01-02" form (interpreted at start of day in the local timezone).
|
|||
|
|
func parseFilterTime(raw string) (time.Time, error) {
|
|||
|
|
raw = strings.TrimSpace(raw)
|
|||
|
|
if raw == "" {
|
|||
|
|
return time.Time{}, nil
|
|||
|
|
}
|
|||
|
|
layouts := []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"}
|
|||
|
|
var lastErr error
|
|||
|
|
for _, layout := range layouts {
|
|||
|
|
if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil {
|
|||
|
|
return t, nil
|
|||
|
|
} else {
|
|||
|
|
lastErr = err
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return time.Time{}, lastErr
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func sliceContains(ss []string, target string) bool {
|
|||
|
|
for _, s := range ss {
|
|||
|
|
if s == target {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type batchReparseKnowledgeRequest struct {
|
|||
|
|
KBID string `json:"kb_id" binding:"required"`
|
|||
|
|
IDs []string `json:"ids" binding:"required"`
|
|||
|
|
ProcessConfig *types.KnowledgeProcessOverrides `json:"process_config,omitempty"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// BatchReparseKnowledge godoc
|
|||
|
|
// @Summary 批量重新解析知识
|
|||
|
|
// @Description 按 ID 列表批量重新解析单个知识库下的多个知识条目
|
|||
|
|
// @Tags 知识管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param request body batchReparseKnowledgeRequest true "批量重解析请求"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "任务已提交"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Failure 403 {object} errors.AppError "权限不足"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /knowledge/batch-reparse [post]
|
|||
|
|
func (h *KnowledgeHandler) BatchReparseKnowledge(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
var req batchReparseKnowledgeRequest
|
|||
|
|
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
logger.Errorf(ctx, "failed to parse batch reparse knowledge request: %v", err)
|
|||
|
|
c.Error(errors.NewBadRequestError("invalid batch reparse knowledge request parameters"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
seen := make(map[string]struct{}, len(req.IDs))
|
|||
|
|
ids := make([]string, 0, len(req.IDs))
|
|||
|
|
for _, raw := range req.IDs {
|
|||
|
|
id := strings.TrimSpace(raw)
|
|||
|
|
if id == "" {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if _, ok := seen[id]; ok {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
seen[id] = struct{}{}
|
|||
|
|
ids = append(ids, id)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if len(ids) == 0 {
|
|||
|
|
c.Error(errors.NewBadRequestError("no knowledge IDs provided for batch reparse"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
const maxBatch = 200
|
|||
|
|
if len(ids) > maxBatch {
|
|||
|
|
c.Error(errors.NewBadRequestError(fmt.Sprintf("too many ids (max %d per batch)", maxBatch)))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_, kbID, effectiveTenantID, permission, err := h.validateKnowledgeBaseAccessWithKBID(c, req.KBID)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if permission != types.OrgRoleAdmin && permission != types.OrgRoleEditor {
|
|||
|
|
c.Error(errors.NewForbiddenError("no permission to reparse knowledge in this kb"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if err := h.requireKBOwnershipOrAdmin(c, kbID); err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
ctx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID)
|
|||
|
|
|
|||
|
|
knowledgeList, err := h.kgService.GetKnowledgeBatch(ctx, effectiveTenantID, ids)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Errorf(ctx, "failed to get knowledge batch, kb_id: %s, size: %d, err: %v", kbID, len(ids), err)
|
|||
|
|
c.Error(errors.NewInternalServerError("failed to get knowledge batch"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if len(knowledgeList) != len(ids) {
|
|||
|
|
c.Error(errors.NewBadRequestError("some knowledge entries were not found"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
for _, k := range knowledgeList {
|
|||
|
|
if k.KnowledgeBaseID != kbID {
|
|||
|
|
c.Error(errors.NewBadRequestError(
|
|||
|
|
fmt.Sprintf("Knowledge %s does not belong to knowledge base %s",
|
|||
|
|
secutils.SanitizeForLog(k.ID), secutils.SanitizeForLog(kbID))))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
taskID, err := h.enqueueKnowledgeListReparse(ctx, effectiveTenantID, ids, req.ProcessConfig)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Errorf(ctx, "Failed to enqueue batch knowledge reparse task: %v", err)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to enqueue batch reparse task"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Batch knowledge reparse task enqueued: %s, kb_id: %s, count: %d",
|
|||
|
|
taskID, secutils.SanitizeForLog(kbID), len(ids))
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"message": "Batch reparse task submitted",
|
|||
|
|
"data": gin.H{
|
|||
|
|
"task_id": taskID,
|
|||
|
|
"reparse_count": len(ids),
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func requireTenantAPIKeyKnowledgeBase(ctx context.Context, kbID string) error {
|
|||
|
|
return requireTenantAPIKeyKnowledgeBases(ctx, kbID)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func requireTenantAPIKeyKnowledgeBases(ctx context.Context, kbIDs ...string) error {
|
|||
|
|
return types.AuthorizeTenantAPIKeyKnowledgeBases(ctx, kbIDs...)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func tenantAPIKeyAllowedKBSet(ctx context.Context) map[string]bool {
|
|||
|
|
scope, ok := types.TenantAPIKeyScopeFromContext(ctx)
|
|||
|
|
if !ok || !scope.IsKnowledgeBaseRestricted() {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
allowed := make(map[string]bool, len(scope.KnowledgeBaseIDs))
|
|||
|
|
for _, id := range scope.KnowledgeBaseIDs {
|
|||
|
|
allowed[id] = true
|
|||
|
|
}
|
|||
|
|
return allowed
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func intersectKBAllowSet(base, restrict map[string]bool) map[string]bool {
|
|||
|
|
if restrict == nil {
|
|||
|
|
return base
|
|||
|
|
}
|
|||
|
|
if base == nil {
|
|||
|
|
return restrict
|
|||
|
|
}
|
|||
|
|
out := make(map[string]bool)
|
|||
|
|
for id := range base {
|
|||
|
|
if restrict[id] {
|
|||
|
|
out[id] = true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return out
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func filterKnowledgesByKBAllowSet(knowledges []*types.Knowledge, allowed map[string]bool) []*types.Knowledge {
|
|||
|
|
if allowed == nil {
|
|||
|
|
return knowledges
|
|||
|
|
}
|
|||
|
|
filtered := make([]*types.Knowledge, 0, len(knowledges))
|
|||
|
|
for _, k := range knowledges {
|
|||
|
|
if k != nil && allowed[k.KnowledgeBaseID] {
|
|||
|
|
filtered = append(filtered, k)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return filtered
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func filterKnowledgeSearchScopesForAPIKey(ctx context.Context, scopes []types.KnowledgeSearchScope) []types.KnowledgeSearchScope {
|
|||
|
|
allowed := tenantAPIKeyAllowedKBSet(ctx)
|
|||
|
|
if allowed == nil {
|
|||
|
|
return scopes
|
|||
|
|
}
|
|||
|
|
filtered := make([]types.KnowledgeSearchScope, 0, len(scopes))
|
|||
|
|
for _, scope := range scopes {
|
|||
|
|
if allowed[scope.KBID] {
|
|||
|
|
filtered = append(filtered, scope)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return filtered
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func tenantAPIKeySearchScopes(ctx context.Context) ([]types.KnowledgeSearchScope, bool) {
|
|||
|
|
scope, ok := types.TenantAPIKeyScopeFromContext(ctx)
|
|||
|
|
if !ok || !scope.IsKnowledgeBaseRestricted() {
|
|||
|
|
return nil, false
|
|||
|
|
}
|
|||
|
|
tenantID, ok := types.TenantIDFromContext(ctx)
|
|||
|
|
if !ok || tenantID == 0 {
|
|||
|
|
return nil, true
|
|||
|
|
}
|
|||
|
|
scopes := make([]types.KnowledgeSearchScope, 0, len(scope.KnowledgeBaseIDs))
|
|||
|
|
for _, kbID := range scope.KnowledgeBaseIDs {
|
|||
|
|
scopes = append(scopes, types.KnowledgeSearchScope{TenantID: tenantID, KBID: kbID})
|
|||
|
|
}
|
|||
|
|
return scopes, true
|
|||
|
|
}
|