dbd7747742
- Implemented KnowledgeWebsiteMarkdownPrompt for formatting webpage content into Markdown. - Added GetItemDetail method in KnowledgeHandler to retrieve knowledge item details. - Introduced KnowledgeDetailDrawer component for displaying knowledge item details in the admin web interface. - Created MarkdownPreview component for rendering Markdown content. - Added knowledge chunking and URL parsing logic to handle webpage content extraction and formatting. - Updated database schema to include markdown_content in knowledge_items table.
2105 lines
60 KiB
Go
2105 lines
60 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"path/filepath"
|
|
"runtime/debug"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
|
"github.com/geo-platform/tenant-api/internal/shared/config"
|
|
"github.com/geo-platform/tenant-api/internal/shared/llm"
|
|
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
|
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
|
"github.com/geo-platform/tenant-api/internal/shared/retrieval"
|
|
"github.com/geo-platform/tenant-api/internal/tenant/prompts"
|
|
)
|
|
|
|
const (
|
|
defaultKnowledgeQueueSize = 128
|
|
defaultKnowledgeCleanupPoll = 15 * time.Second
|
|
defaultKnowledgeChildGroupName = "默认目录"
|
|
)
|
|
|
|
type KnowledgeService struct {
|
|
pool *pgxpool.Pool
|
|
provider retrieval.Provider
|
|
store retrieval.VectorStore
|
|
objectStorage objectstorage.Client
|
|
llmClient llm.Client
|
|
logger *zap.Logger
|
|
chunkSize int
|
|
chunkOverlap int
|
|
embeddingBatchSize int
|
|
recallLimit int
|
|
rerankTopN int
|
|
arkBaseURL string
|
|
arkAPIKey string
|
|
urlMarkdownModel string
|
|
httpClient *http.Client
|
|
jobs chan knowledgeParseJob
|
|
cleanupMu sync.Mutex
|
|
cleanupInFlight map[string]struct{}
|
|
}
|
|
|
|
type knowledgeParseJob struct {
|
|
TenantID int64
|
|
ItemID int64
|
|
ParseTaskID int64
|
|
}
|
|
|
|
type knowledgeCleanupJob struct {
|
|
TenantID int64
|
|
ItemID int64
|
|
}
|
|
|
|
type knowledgeItemRecord struct {
|
|
ID int64
|
|
TenantID int64
|
|
GroupID int64
|
|
GroupName string
|
|
Name string
|
|
SourceType string
|
|
SourceURI *string
|
|
StorageKey string
|
|
ContentText *string
|
|
Markdown *string
|
|
ItemVersion int
|
|
}
|
|
|
|
type KnowledgeGroupRequest struct {
|
|
Name string `json:"name" binding:"required"`
|
|
ParentID *int64 `json:"parent_id"`
|
|
SortOrder *int `json:"sort_order"`
|
|
}
|
|
|
|
type KnowledgeGroupResponse struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
ParentID *int64 `json:"parent_id"`
|
|
SortOrder int `json:"sort_order"`
|
|
ItemCount int64 `json:"item_count"`
|
|
CreatedAt string `json:"created_at"`
|
|
Children []KnowledgeGroupResponse `json:"children"`
|
|
}
|
|
|
|
type KnowledgeItemResponse struct {
|
|
ID int64 `json:"id"`
|
|
GroupID int64 `json:"group_id"`
|
|
GroupName string `json:"group_name"`
|
|
Name string `json:"name"`
|
|
SourceType string `json:"source_type"`
|
|
SourceURI *string `json:"source_uri"`
|
|
Status string `json:"status"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
ErrorMessage *string `json:"error_message"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
type KnowledgeItemDetailResponse struct {
|
|
ID int64 `json:"id"`
|
|
GroupID int64 `json:"group_id"`
|
|
GroupName string `json:"group_name"`
|
|
Name string `json:"name"`
|
|
SourceType string `json:"source_type"`
|
|
SourceURI *string `json:"source_uri"`
|
|
Status string `json:"status"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
ErrorMessage *string `json:"error_message"`
|
|
MarkdownContent *string `json:"markdown_content"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
type KnowledgeTextItemRequest struct {
|
|
GroupID int64 `json:"group_id" binding:"required"`
|
|
Name string `json:"name" binding:"required"`
|
|
Content string `json:"content" binding:"required"`
|
|
}
|
|
|
|
type KnowledgeURLItemRequest struct {
|
|
GroupID int64 `json:"group_id" binding:"required"`
|
|
Name string `json:"name" binding:"required"`
|
|
URL string `json:"url" binding:"required"`
|
|
}
|
|
|
|
type KnowledgeContext struct {
|
|
GroupIDs []int64
|
|
GroupNames []string
|
|
Snippets []KnowledgeSnippet
|
|
}
|
|
|
|
type KnowledgeSnippet struct {
|
|
PointID string
|
|
GroupID int64
|
|
GroupName string
|
|
KnowledgeItemID int64
|
|
ItemName string
|
|
Text string
|
|
Score float64
|
|
}
|
|
|
|
func NewKnowledgeService(
|
|
pool *pgxpool.Pool,
|
|
provider retrieval.Provider,
|
|
store retrieval.VectorStore,
|
|
objectStorage objectstorage.Client,
|
|
llmClient llm.Client,
|
|
logger *zap.Logger,
|
|
cfg config.RetrievalConfig,
|
|
llmCfg config.LLMConfig,
|
|
queueSize int,
|
|
workerCount int,
|
|
) *KnowledgeService {
|
|
chunkSize := cfg.ChunkSize
|
|
if chunkSize <= 0 {
|
|
chunkSize = 900
|
|
}
|
|
chunkOverlap := cfg.ChunkOverlap
|
|
if chunkOverlap < 0 {
|
|
chunkOverlap = 120
|
|
}
|
|
batchSize := cfg.EmbeddingBatchSize
|
|
if batchSize <= 0 {
|
|
batchSize = 16
|
|
}
|
|
recallLimit := cfg.RecallLimit
|
|
if recallLimit <= 0 {
|
|
recallLimit = 12
|
|
}
|
|
rerankTopN := cfg.RerankTopN
|
|
if rerankTopN <= 0 {
|
|
rerankTopN = 6
|
|
}
|
|
|
|
svc := &KnowledgeService{
|
|
pool: pool,
|
|
provider: provider,
|
|
store: store,
|
|
objectStorage: objectStorage,
|
|
llmClient: llmClient,
|
|
logger: logger,
|
|
chunkSize: chunkSize,
|
|
chunkOverlap: chunkOverlap,
|
|
embeddingBatchSize: batchSize,
|
|
recallLimit: recallLimit,
|
|
rerankTopN: rerankTopN,
|
|
arkBaseURL: strings.TrimSpace(llmCfg.BaseURL),
|
|
arkAPIKey: strings.TrimSpace(llmCfg.APIKey),
|
|
urlMarkdownModel: strings.TrimSpace(llmCfg.KnowledgeURLModel),
|
|
httpClient: &http.Client{Timeout: defaultKnowledgeURLParseTimeout},
|
|
cleanupInFlight: make(map[string]struct{}),
|
|
}
|
|
if svc.urlMarkdownModel == "" {
|
|
svc.urlMarkdownModel = strings.TrimSpace(llmCfg.Model)
|
|
}
|
|
if svc.arkBaseURL == "" {
|
|
svc.arkBaseURL = defaultKnowledgeArkBaseURL
|
|
}
|
|
|
|
if workerCount > 0 {
|
|
if queueSize <= 0 {
|
|
queueSize = defaultKnowledgeQueueSize
|
|
}
|
|
svc.jobs = make(chan knowledgeParseJob, queueSize)
|
|
for i := 0; i < workerCount; i++ {
|
|
go svc.runParseWorker()
|
|
}
|
|
go svc.recoverPendingParseTasks()
|
|
go svc.runDeletedCleanupWorker()
|
|
}
|
|
|
|
return svc
|
|
}
|
|
|
|
func (s *KnowledgeService) ListGroups(ctx context.Context) ([]KnowledgeGroupResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, name, parent_id, sort_order, created_at
|
|
FROM knowledge_groups
|
|
WHERE tenant_id = $1 AND deleted_at IS NULL
|
|
ORDER BY sort_order, created_at, id
|
|
`, actor.TenantID)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50050, "knowledge_groups_query_failed", "failed to list knowledge groups")
|
|
}
|
|
defer rows.Close()
|
|
|
|
nodeMap := make(map[int64]*KnowledgeGroupResponse)
|
|
parentMap := make(map[int64][]int64)
|
|
orderedIDs := make([]int64, 0)
|
|
for rows.Next() {
|
|
node := &KnowledgeGroupResponse{}
|
|
var createdAt time.Time
|
|
if err := rows.Scan(&node.ID, &node.Name, &node.ParentID, &node.SortOrder, &createdAt); err != nil {
|
|
return nil, response.ErrInternal(50050, "knowledge_groups_scan_failed", err.Error())
|
|
}
|
|
node.CreatedAt = createdAt.Format(time.RFC3339)
|
|
node.Children = []KnowledgeGroupResponse{}
|
|
nodeMap[node.ID] = node
|
|
orderedIDs = append(orderedIDs, node.ID)
|
|
if node.ParentID != nil {
|
|
parentMap[*node.ParentID] = append(parentMap[*node.ParentID], node.ID)
|
|
}
|
|
}
|
|
|
|
countRows, err := s.pool.Query(ctx, `
|
|
SELECT group_id, COUNT(*)::bigint
|
|
FROM knowledge_items
|
|
WHERE tenant_id = $1 AND deleted_at IS NULL
|
|
GROUP BY group_id
|
|
`, actor.TenantID)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50050, "knowledge_group_counts_query_failed", "failed to list knowledge group counts")
|
|
}
|
|
defer countRows.Close()
|
|
|
|
directCounts := make(map[int64]int64)
|
|
for countRows.Next() {
|
|
var groupID, itemCount int64
|
|
if err := countRows.Scan(&groupID, &itemCount); err != nil {
|
|
return nil, response.ErrInternal(50050, "knowledge_group_counts_scan_failed", err.Error())
|
|
}
|
|
directCounts[groupID] = itemCount
|
|
}
|
|
|
|
var sumCounts func(int64) int64
|
|
sumCounts = func(groupID int64) int64 {
|
|
total := directCounts[groupID]
|
|
for _, childID := range parentMap[groupID] {
|
|
total += sumCounts(childID)
|
|
}
|
|
if node := nodeMap[groupID]; node != nil {
|
|
node.ItemCount = total
|
|
}
|
|
return total
|
|
}
|
|
|
|
var buildTree func(int64) KnowledgeGroupResponse
|
|
buildTree = func(groupID int64) KnowledgeGroupResponse {
|
|
node := nodeMap[groupID]
|
|
if node == nil {
|
|
return KnowledgeGroupResponse{}
|
|
}
|
|
result := *node
|
|
childIDs := parentMap[groupID]
|
|
result.Children = make([]KnowledgeGroupResponse, 0, len(childIDs))
|
|
for _, childID := range childIDs {
|
|
result.Children = append(result.Children, buildTree(childID))
|
|
}
|
|
return result
|
|
}
|
|
|
|
roots := make([]KnowledgeGroupResponse, 0)
|
|
for _, groupID := range orderedIDs {
|
|
node := nodeMap[groupID]
|
|
if node == nil {
|
|
continue
|
|
}
|
|
if node.ParentID == nil {
|
|
sumCounts(groupID)
|
|
roots = append(roots, buildTree(groupID))
|
|
}
|
|
}
|
|
|
|
return roots, nil
|
|
}
|
|
|
|
func (s *KnowledgeService) CreateGroup(ctx context.Context, req KnowledgeGroupRequest) (*KnowledgeGroupResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
|
|
name := strings.TrimSpace(req.Name)
|
|
if name == "" {
|
|
return nil, response.ErrBadRequest(40050, "knowledge_group_name_required", "knowledge group name is required")
|
|
}
|
|
|
|
var parentID *int64
|
|
if req.ParentID != nil && *req.ParentID > 0 {
|
|
if err := s.ensureRootGroup(ctx, actor.TenantID, *req.ParentID); err != nil {
|
|
return nil, err
|
|
}
|
|
parentID = req.ParentID
|
|
}
|
|
|
|
sortOrder := 0
|
|
if req.SortOrder != nil {
|
|
sortOrder = *req.SortOrder
|
|
}
|
|
|
|
var (
|
|
groupID int64
|
|
createdAt time.Time
|
|
)
|
|
err := s.pool.QueryRow(ctx, `
|
|
INSERT INTO knowledge_groups (tenant_id, name, parent_id, sort_order)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id, created_at
|
|
`, actor.TenantID, name, parentID, sortOrder).Scan(&groupID, &createdAt)
|
|
if err != nil {
|
|
return nil, response.ErrConflict(40950, "knowledge_group_create_failed", "knowledge group already exists or cannot be created")
|
|
}
|
|
|
|
return &KnowledgeGroupResponse{
|
|
ID: groupID,
|
|
Name: name,
|
|
ParentID: parentID,
|
|
SortOrder: sortOrder,
|
|
ItemCount: 0,
|
|
CreatedAt: createdAt.Format(time.RFC3339),
|
|
Children: []KnowledgeGroupResponse{},
|
|
}, nil
|
|
}
|
|
|
|
func (s *KnowledgeService) UpdateGroup(ctx context.Context, groupID int64, req KnowledgeGroupRequest) error {
|
|
actor := auth.MustActor(ctx)
|
|
|
|
name := strings.TrimSpace(req.Name)
|
|
if name == "" {
|
|
return response.ErrBadRequest(40050, "knowledge_group_name_required", "knowledge group name is required")
|
|
}
|
|
|
|
sortOrder := 0
|
|
if req.SortOrder != nil {
|
|
sortOrder = *req.SortOrder
|
|
}
|
|
|
|
tag, err := s.pool.Exec(ctx, `
|
|
UPDATE knowledge_groups
|
|
SET name = $1, sort_order = $2, updated_at = NOW()
|
|
WHERE id = $3 AND tenant_id = $4 AND deleted_at IS NULL
|
|
`, name, sortOrder, groupID, actor.TenantID)
|
|
if err != nil {
|
|
return response.ErrConflict(40950, "knowledge_group_update_failed", "knowledge group already exists or cannot be updated")
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return response.ErrNotFound(40451, "knowledge_group_not_found", "knowledge group not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *KnowledgeService) DeleteGroup(ctx context.Context, groupID int64) error {
|
|
actor := auth.MustActor(ctx)
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return response.ErrInternal(50054, "knowledge_group_delete_failed", "failed to begin delete transaction")
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
groupIDs, err := s.listGroupSubtreeIDs(ctx, tx, actor.TenantID, groupID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(groupIDs) == 0 {
|
|
return response.ErrNotFound(40451, "knowledge_group_not_found", "knowledge group not found")
|
|
}
|
|
|
|
itemIDs, err := s.listActiveKnowledgeItemIDs(ctx, tx, actor.TenantID, groupIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(itemIDs) > 0 {
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE knowledge_items
|
|
SET deleted_at = NOW(), updated_at = NOW(), status = 'deleted'
|
|
WHERE tenant_id = $1
|
|
AND deleted_at IS NULL
|
|
AND id = ANY($2::bigint[])
|
|
`, actor.TenantID, itemIDs); err != nil {
|
|
return response.ErrInternal(50054, "knowledge_group_delete_failed", "failed to delete knowledge items")
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE knowledge_parse_tasks
|
|
SET status = 'deleted',
|
|
error_message = 'knowledge group deleted',
|
|
completed_at = COALESCE(completed_at, NOW()),
|
|
updated_at = NOW()
|
|
WHERE tenant_id = $1
|
|
AND knowledge_item_id = ANY($2::bigint[])
|
|
AND status <> 'deleted'
|
|
`, actor.TenantID, itemIDs); err != nil {
|
|
return response.ErrInternal(50054, "knowledge_group_delete_failed", "failed to cancel knowledge parse tasks")
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE knowledge_chunks_meta
|
|
SET status = 'deleting', updated_at = NOW()
|
|
WHERE tenant_id = $1
|
|
AND knowledge_item_id = ANY($2::bigint[])
|
|
AND status <> 'deleted'
|
|
`, actor.TenantID, itemIDs); err != nil {
|
|
return response.ErrInternal(50054, "knowledge_group_delete_failed", "failed to mark knowledge chunks for deletion")
|
|
}
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
DELETE FROM prompt_rule_knowledge_groups
|
|
WHERE knowledge_group_id = ANY($1::bigint[])
|
|
`, groupIDs); err != nil {
|
|
return response.ErrInternal(50054, "knowledge_group_delete_failed", "failed to delete knowledge group references")
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE knowledge_groups
|
|
SET deleted_at = NOW(), updated_at = NOW()
|
|
WHERE tenant_id = $1
|
|
AND deleted_at IS NULL
|
|
AND id = ANY($2::bigint[])
|
|
`, actor.TenantID, groupIDs); err != nil {
|
|
return response.ErrInternal(50054, "knowledge_group_delete_failed", "failed to delete knowledge groups")
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return response.ErrInternal(50054, "knowledge_group_delete_failed", "failed to commit delete transaction")
|
|
}
|
|
|
|
for _, itemID := range itemIDs {
|
|
go s.cleanupDeletedKnowledgeItemAsync(actor.TenantID, itemID)
|
|
}
|
|
|
|
if s.logger != nil {
|
|
s.logger.Info("knowledge group soft deleted",
|
|
zap.Int64("tenant_id", actor.TenantID),
|
|
zap.Int64("knowledge_group_id", groupID),
|
|
zap.Int("group_count", len(groupIDs)),
|
|
zap.Int("item_count", len(itemIDs)),
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *KnowledgeService) ListItems(ctx context.Context, groupID *int64) ([]KnowledgeItemResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
if groupID != nil && *groupID > 0 {
|
|
if err := s.ensureGroupAccessible(ctx, actor.TenantID, *groupID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
query := `
|
|
SELECT ki.id, ki.group_id, kg.name, ki.name, ki.source_type, ki.source_uri, ki.status,
|
|
ki.size_bytes, ki.error_message, ki.created_at, ki.updated_at
|
|
FROM knowledge_items ki
|
|
JOIN knowledge_groups kg ON kg.id = ki.group_id AND kg.deleted_at IS NULL
|
|
WHERE ki.tenant_id = $1
|
|
AND ki.deleted_at IS NULL
|
|
`
|
|
args := []any{actor.TenantID}
|
|
if groupID != nil && *groupID > 0 {
|
|
query += `
|
|
AND ki.group_id IN (
|
|
WITH RECURSIVE selected_groups AS (
|
|
SELECT id
|
|
FROM knowledge_groups
|
|
WHERE id = $2 AND tenant_id = $1 AND deleted_at IS NULL
|
|
UNION ALL
|
|
SELECT child.id
|
|
FROM knowledge_groups child
|
|
JOIN selected_groups parent ON child.parent_id = parent.id
|
|
WHERE child.tenant_id = $1 AND child.deleted_at IS NULL
|
|
)
|
|
SELECT id FROM selected_groups
|
|
)
|
|
`
|
|
args = append(args, *groupID)
|
|
}
|
|
query += ` ORDER BY ki.created_at DESC, ki.id DESC`
|
|
|
|
rows, err := s.pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50051, "knowledge_items_query_failed", "failed to list knowledge items")
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := make([]KnowledgeItemResponse, 0)
|
|
for rows.Next() {
|
|
var (
|
|
item KnowledgeItemResponse
|
|
sourceURI *string
|
|
errorMessage *string
|
|
createdAt, updatedAt time.Time
|
|
)
|
|
if err := rows.Scan(
|
|
&item.ID,
|
|
&item.GroupID,
|
|
&item.GroupName,
|
|
&item.Name,
|
|
&item.SourceType,
|
|
&sourceURI,
|
|
&item.Status,
|
|
&item.SizeBytes,
|
|
&errorMessage,
|
|
&createdAt,
|
|
&updatedAt,
|
|
); err != nil {
|
|
return nil, response.ErrInternal(50051, "knowledge_items_scan_failed", err.Error())
|
|
}
|
|
item.SourceURI = sourceURI
|
|
item.ErrorMessage = errorMessage
|
|
item.CreatedAt = createdAt.Format(time.RFC3339)
|
|
item.UpdatedAt = updatedAt.Format(time.RFC3339)
|
|
items = append(items, item)
|
|
}
|
|
|
|
return items, nil
|
|
}
|
|
|
|
func (s *KnowledgeService) GetItemDetail(ctx context.Context, itemID int64) (*KnowledgeItemDetailResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
|
|
var (
|
|
item KnowledgeItemDetailResponse
|
|
sourceURI *string
|
|
errorMessage *string
|
|
markdownContent *string
|
|
createdAt, updatedAt time.Time
|
|
)
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT ki.id, ki.group_id, kg.name, ki.name, ki.source_type, ki.source_uri, ki.status,
|
|
ki.size_bytes, ki.error_message, ki.markdown_content, ki.created_at, ki.updated_at
|
|
FROM knowledge_items ki
|
|
JOIN knowledge_groups kg ON kg.id = ki.group_id AND kg.deleted_at IS NULL
|
|
WHERE ki.id = $1 AND ki.tenant_id = $2 AND ki.deleted_at IS NULL
|
|
`, itemID, actor.TenantID).Scan(
|
|
&item.ID,
|
|
&item.GroupID,
|
|
&item.GroupName,
|
|
&item.Name,
|
|
&item.SourceType,
|
|
&sourceURI,
|
|
&item.Status,
|
|
&item.SizeBytes,
|
|
&errorMessage,
|
|
&markdownContent,
|
|
&createdAt,
|
|
&updatedAt,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, response.ErrNotFound(40450, "knowledge_item_not_found", "knowledge item not found")
|
|
}
|
|
return nil, response.ErrInternal(50051, "knowledge_items_query_failed", "failed to load knowledge item")
|
|
}
|
|
|
|
item.SourceURI = sourceURI
|
|
item.ErrorMessage = errorMessage
|
|
item.MarkdownContent = markdownContent
|
|
item.CreatedAt = createdAt.Format(time.RFC3339)
|
|
item.UpdatedAt = updatedAt.Format(time.RFC3339)
|
|
return &item, nil
|
|
}
|
|
|
|
func (s *KnowledgeService) CreateTextItem(ctx context.Context, req KnowledgeTextItemRequest) (*KnowledgeItemResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
|
|
if err := s.validateIngestionDependencies("text"); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
content := normalizeKnowledgeText(req.Content)
|
|
if content == "" {
|
|
return nil, response.ErrBadRequest(40051, "knowledge_content_required", "knowledge content is required")
|
|
}
|
|
|
|
return s.queueKnowledgeItem(ctx, actor.TenantID, knowledgeIngestInput{
|
|
GroupID: req.GroupID,
|
|
Name: strings.TrimSpace(req.Name),
|
|
SourceType: "text",
|
|
ContentText: &content,
|
|
SizeBytes: int64(len([]byte(content))),
|
|
})
|
|
}
|
|
|
|
func (s *KnowledgeService) CreateURLItem(ctx context.Context, req KnowledgeURLItemRequest) (*KnowledgeItemResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
|
|
if err := s.validateIngestionDependencies("website"); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rawURL := strings.TrimSpace(req.URL)
|
|
if _, err := url.ParseRequestURI(rawURL); err != nil {
|
|
return nil, response.ErrBadRequest(40052, "knowledge_url_invalid", "knowledge url is invalid")
|
|
}
|
|
|
|
return s.queueKnowledgeItem(ctx, actor.TenantID, knowledgeIngestInput{
|
|
GroupID: req.GroupID,
|
|
Name: strings.TrimSpace(req.Name),
|
|
SourceType: "website",
|
|
SourceURI: &rawURL,
|
|
})
|
|
}
|
|
|
|
func (s *KnowledgeService) CreateFileItem(
|
|
ctx context.Context,
|
|
groupID int64,
|
|
name string,
|
|
fileName string,
|
|
content []byte,
|
|
) (*KnowledgeItemResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
|
|
if err := s.validateIngestionDependencies("document"); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
displayName := strings.TrimSpace(name)
|
|
if displayName == "" {
|
|
displayName = strings.TrimSuffix(filepath.Base(fileName), filepath.Ext(fileName))
|
|
}
|
|
|
|
objectKey := buildKnowledgeObjectKey(actor.TenantID, "document", fileName)
|
|
if err := s.objectStorage.PutBytes(ctx, objectKey, content, detectKnowledgeContentType(fileName)); err != nil {
|
|
return nil, response.ErrServiceUnavailable(50357, "knowledge_storage_unavailable", err.Error())
|
|
}
|
|
|
|
return s.queueKnowledgeItem(ctx, actor.TenantID, knowledgeIngestInput{
|
|
GroupID: groupID,
|
|
Name: displayName,
|
|
SourceType: "document",
|
|
StorageKey: objectKey,
|
|
SizeBytes: int64(len(content)),
|
|
})
|
|
}
|
|
|
|
func (s *KnowledgeService) DeleteItem(ctx context.Context, itemID int64) error {
|
|
actor := auth.MustActor(ctx)
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return response.ErrInternal(50054, "knowledge_item_delete_failed", "failed to begin delete transaction")
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
tag, err := tx.Exec(ctx, `
|
|
UPDATE knowledge_items
|
|
SET deleted_at = NOW(), updated_at = NOW(), status = 'deleted'
|
|
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
|
`, itemID, actor.TenantID)
|
|
if err != nil {
|
|
return response.ErrInternal(50054, "knowledge_item_delete_failed", "failed to delete knowledge item")
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return response.ErrNotFound(40450, "knowledge_item_not_found", "knowledge item not found")
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE knowledge_parse_tasks
|
|
SET status = 'deleted',
|
|
error_message = 'knowledge item deleted',
|
|
completed_at = COALESCE(completed_at, NOW()),
|
|
updated_at = NOW()
|
|
WHERE knowledge_item_id = $1
|
|
AND tenant_id = $2
|
|
AND status IN ('pending', 'running')
|
|
`, itemID, actor.TenantID); err != nil {
|
|
return response.ErrInternal(50054, "knowledge_item_delete_failed", "failed to cancel knowledge parse task")
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE knowledge_chunks_meta
|
|
SET status = 'deleting', updated_at = NOW()
|
|
WHERE knowledge_item_id = $1 AND tenant_id = $2 AND status <> 'deleted'
|
|
`, itemID, actor.TenantID); err != nil {
|
|
return response.ErrInternal(50054, "knowledge_item_delete_failed", "failed to mark knowledge chunks for deletion")
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return response.ErrInternal(50054, "knowledge_item_delete_failed", "failed to commit delete transaction")
|
|
}
|
|
|
|
if s.logger != nil {
|
|
s.logger.Info("knowledge item soft deleted",
|
|
zap.Int64("tenant_id", actor.TenantID),
|
|
zap.Int64("knowledge_item_id", itemID),
|
|
)
|
|
}
|
|
go s.cleanupDeletedKnowledgeItemAsync(actor.TenantID, itemID)
|
|
return nil
|
|
}
|
|
|
|
func (s *KnowledgeService) ResolveContext(
|
|
ctx context.Context,
|
|
tenantID int64,
|
|
groupIDs []int64,
|
|
query string,
|
|
) (*KnowledgeContext, error) {
|
|
groupIDs = normalizeInt64IDs(groupIDs)
|
|
if len(groupIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
if strings.TrimSpace(query) == "" {
|
|
return nil, nil
|
|
}
|
|
if err := s.provider.Validate(); err != nil {
|
|
return nil, response.ErrServiceUnavailable(50351, "retrieval_unavailable", err.Error())
|
|
}
|
|
if err := s.store.Validate(); err != nil {
|
|
return nil, response.ErrServiceUnavailable(50352, "qdrant_unavailable", err.Error())
|
|
}
|
|
|
|
groupNames, err := s.listGroupNames(ctx, tenantID, groupIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(groupNames) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
vectors, err := s.provider.Embed(ctx, []string{query})
|
|
if err != nil {
|
|
return nil, response.ErrServiceUnavailable(50353, "embedding_failed", err.Error())
|
|
}
|
|
if len(vectors) == 0 || len(vectors[0]) == 0 {
|
|
return nil, response.ErrServiceUnavailable(50353, "embedding_failed", "embedding vector is empty")
|
|
}
|
|
|
|
searchPoints, err := s.store.Search(ctx, retrieval.SearchRequest{
|
|
Vector: vectors[0],
|
|
TenantID: tenantID,
|
|
GroupIDs: groupIDs,
|
|
Limit: s.recallLimit,
|
|
})
|
|
if err != nil {
|
|
return nil, response.ErrServiceUnavailable(50354, "qdrant_search_failed", err.Error())
|
|
}
|
|
if len(searchPoints) == 0 {
|
|
return &KnowledgeContext{GroupIDs: groupIDs, GroupNames: groupNames, Snippets: []KnowledgeSnippet{}}, nil
|
|
}
|
|
|
|
candidates := make([]KnowledgeSnippet, 0, len(searchPoints))
|
|
documents := make([]string, 0, len(searchPoints))
|
|
for _, point := range searchPoints {
|
|
text := strings.TrimSpace(anyString(point.Payload["text"]))
|
|
if text == "" {
|
|
continue
|
|
}
|
|
candidates = append(candidates, KnowledgeSnippet{
|
|
PointID: point.ID,
|
|
GroupID: anyInt64(point.Payload["group_id"]),
|
|
GroupName: strings.TrimSpace(anyString(point.Payload["group_name"])),
|
|
KnowledgeItemID: anyInt64(point.Payload["item_id"]),
|
|
ItemName: strings.TrimSpace(anyString(point.Payload["item_name"])),
|
|
Text: text,
|
|
Score: point.Score,
|
|
})
|
|
documents = append(documents, text)
|
|
}
|
|
if len(candidates) == 0 {
|
|
return &KnowledgeContext{GroupIDs: groupIDs, GroupNames: groupNames, Snippets: []KnowledgeSnippet{}}, nil
|
|
}
|
|
|
|
candidates, err = s.filterActiveKnowledgeSnippets(ctx, tenantID, candidates)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(candidates) == 0 {
|
|
return &KnowledgeContext{GroupIDs: groupIDs, GroupNames: groupNames, Snippets: []KnowledgeSnippet{}}, nil
|
|
}
|
|
|
|
documents = documents[:0]
|
|
for _, candidate := range candidates {
|
|
documents = append(documents, candidate.Text)
|
|
}
|
|
|
|
reranked, err := s.provider.Rerank(ctx, query, documents, minInt(s.rerankTopN, len(documents)))
|
|
if err != nil {
|
|
selected := candidates[:minInt(len(candidates), s.rerankTopN)]
|
|
return &KnowledgeContext{GroupIDs: groupIDs, GroupNames: groupNames, Snippets: selected}, nil
|
|
}
|
|
|
|
selected := make([]KnowledgeSnippet, 0, len(reranked))
|
|
for _, item := range reranked {
|
|
if item.Index < 0 || item.Index >= len(candidates) {
|
|
continue
|
|
}
|
|
snippet := candidates[item.Index]
|
|
snippet.Score = item.RelevanceScore
|
|
if strings.TrimSpace(item.Text) != "" {
|
|
snippet.Text = strings.TrimSpace(item.Text)
|
|
}
|
|
selected = append(selected, snippet)
|
|
}
|
|
|
|
return &KnowledgeContext{
|
|
GroupIDs: groupIDs,
|
|
GroupNames: groupNames,
|
|
Snippets: selected,
|
|
}, nil
|
|
}
|
|
|
|
func (s *KnowledgeService) RenderPromptSection(ctx *KnowledgeContext) string {
|
|
if ctx == nil || len(ctx.Snippets) == 0 {
|
|
return ""
|
|
}
|
|
|
|
lines := prompts.KnowledgePromptIntroLines()
|
|
for index, snippet := range ctx.Snippets {
|
|
label := strings.TrimSpace(snippet.ItemName)
|
|
if label == "" {
|
|
label = prompts.KnowledgeSnippetLabel(index)
|
|
}
|
|
groupName := strings.TrimSpace(snippet.GroupName)
|
|
if groupName != "" {
|
|
label = fmt.Sprintf("%s / %s", groupName, label)
|
|
}
|
|
lines = append(lines, fmt.Sprintf("%d. %s\n%s", index+1, label, snippet.Text))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
type knowledgeIngestInput struct {
|
|
GroupID int64
|
|
Name string
|
|
SourceType string
|
|
SourceURI *string
|
|
StorageKey string
|
|
ContentText *string
|
|
SizeBytes int64
|
|
}
|
|
|
|
func (s *KnowledgeService) validateIngestionDependencies(sourceType string) error {
|
|
if err := s.provider.Validate(); err != nil {
|
|
return response.ErrServiceUnavailable(50351, "retrieval_unavailable", err.Error())
|
|
}
|
|
if err := s.store.Validate(); err != nil {
|
|
return response.ErrServiceUnavailable(50352, "qdrant_unavailable", err.Error())
|
|
}
|
|
if sourceType == "document" {
|
|
if err := s.objectStorage.Validate(); err != nil {
|
|
return response.ErrServiceUnavailable(50357, "knowledge_storage_unavailable", err.Error())
|
|
}
|
|
}
|
|
if sourceType == "website" {
|
|
if strings.TrimSpace(s.arkAPIKey) == "" {
|
|
return response.ErrServiceUnavailable(50358, "knowledge_webpage_parser_unavailable", "ark api key is not configured")
|
|
}
|
|
if err := s.llmClient.Validate(); err != nil {
|
|
return response.ErrServiceUnavailable(50359, "knowledge_markdown_formatter_unavailable", err.Error())
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *KnowledgeService) queueKnowledgeItem(
|
|
ctx context.Context,
|
|
tenantID int64,
|
|
input knowledgeIngestInput,
|
|
) (*KnowledgeItemResponse, error) {
|
|
resolvedGroupID, err := s.resolveKnowledgeStorageGroupID(ctx, tenantID, input.GroupID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
input.GroupID = resolvedGroupID
|
|
|
|
input.Name = strings.TrimSpace(input.Name)
|
|
if input.Name == "" {
|
|
return nil, response.ErrBadRequest(40054, "knowledge_name_required", "knowledge item name is required")
|
|
}
|
|
|
|
if input.ContentText != nil {
|
|
contentText := normalizeKnowledgeText(*input.ContentText)
|
|
if contentText == "" {
|
|
return nil, response.ErrBadRequest(40051, "knowledge_content_required", "knowledge content is required")
|
|
}
|
|
input.ContentText = &contentText
|
|
}
|
|
|
|
itemID, parseTaskID, err := s.createPendingKnowledgeItem(ctx, tenantID, input)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := s.enqueueParseJob(knowledgeParseJob{
|
|
TenantID: tenantID,
|
|
ItemID: itemID,
|
|
ParseTaskID: parseTaskID,
|
|
}); err != nil {
|
|
_ = s.markKnowledgeIngestionFailed(context.Background(), tenantID, itemID, parseTaskID, err)
|
|
return nil, response.ErrServiceUnavailable(50356, "knowledge_queue_unavailable", "knowledge parse queue is busy, please retry")
|
|
}
|
|
|
|
items, err := s.listKnowledgeItemsByIDs(ctx, tenantID, []int64{itemID})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(items) == 0 {
|
|
return nil, response.ErrInternal(50055, "knowledge_item_create_failed", "knowledge item was not found after creation")
|
|
}
|
|
return &items[0], nil
|
|
}
|
|
|
|
func (s *KnowledgeService) enqueueParseJob(job knowledgeParseJob) error {
|
|
if s.jobs == nil {
|
|
return fmt.Errorf("knowledge parse workers are not enabled")
|
|
}
|
|
|
|
select {
|
|
case s.jobs <- job:
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("knowledge parse queue is full")
|
|
}
|
|
}
|
|
|
|
func (s *KnowledgeService) runParseWorker() {
|
|
for job := range s.jobs {
|
|
func(job knowledgeParseJob) {
|
|
defer func() {
|
|
if recovered := recover(); recovered != nil {
|
|
err := fmt.Errorf("knowledge parse panic: %v", recovered)
|
|
if s.logger != nil {
|
|
s.logger.Error("knowledge parse worker panicked",
|
|
zap.Int64("tenant_id", job.TenantID),
|
|
zap.Int64("knowledge_item_id", job.ItemID),
|
|
zap.Int64("parse_task_id", job.ParseTaskID),
|
|
zap.Any("panic", recovered),
|
|
zap.ByteString("stack", debug.Stack()),
|
|
)
|
|
}
|
|
_ = s.markKnowledgeIngestionFailed(context.Background(), job.TenantID, job.ItemID, job.ParseTaskID, err)
|
|
}
|
|
}()
|
|
|
|
s.executeParseJob(context.Background(), job)
|
|
}(job)
|
|
}
|
|
}
|
|
|
|
func (s *KnowledgeService) recoverPendingParseTasks() {
|
|
if s.jobs == nil {
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT kpt.tenant_id, kpt.knowledge_item_id, kpt.id
|
|
FROM knowledge_parse_tasks kpt
|
|
JOIN knowledge_items ki ON ki.id = kpt.knowledge_item_id
|
|
WHERE kpt.status IN ('pending', 'running')
|
|
AND ki.deleted_at IS NULL
|
|
AND ki.status IN ('pending', 'processing')
|
|
ORDER BY kpt.created_at ASC, kpt.id ASC
|
|
`)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var job knowledgeParseJob
|
|
if scanErr := rows.Scan(&job.TenantID, &job.ItemID, &job.ParseTaskID); scanErr != nil {
|
|
continue
|
|
}
|
|
if err := s.enqueueParseJob(job); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *KnowledgeService) executeParseJob(ctx context.Context, job knowledgeParseJob) {
|
|
item, err := s.loadKnowledgeItemForParse(ctx, job.TenantID, job.ItemID)
|
|
if err != nil || item == nil {
|
|
return
|
|
}
|
|
|
|
if err := s.markKnowledgeIngestionRunning(ctx, job.TenantID, job.ItemID, job.ParseTaskID); err != nil {
|
|
_ = s.markKnowledgeIngestionFailed(context.Background(), job.TenantID, job.ItemID, job.ParseTaskID, err)
|
|
return
|
|
}
|
|
|
|
parsedContent, err := s.readKnowledgeItemParsedContent(ctx, item)
|
|
if err != nil {
|
|
_ = s.markKnowledgeIngestionFailed(context.Background(), job.TenantID, job.ItemID, job.ParseTaskID, err)
|
|
return
|
|
}
|
|
if item.SourceType == "website" {
|
|
if resolvedName := extractKnowledgeMarkdownTitle(parsedContent.Markdown); resolvedName != "" {
|
|
item.Name = resolvedName
|
|
}
|
|
}
|
|
|
|
chunks := splitKnowledgeTextIntoChunks(parsedContent.Text, s.chunkSize, s.chunkOverlap)
|
|
if len(chunks) == 0 {
|
|
err = errors.New("knowledge content is empty after parsing")
|
|
_ = s.markKnowledgeIngestionFailed(context.Background(), job.TenantID, job.ItemID, job.ParseTaskID, err)
|
|
return
|
|
}
|
|
|
|
pointIDs, err := s.listKnowledgePointIDs(ctx, job.TenantID, job.ItemID)
|
|
if err != nil {
|
|
_ = s.markKnowledgeIngestionFailed(context.Background(), job.TenantID, job.ItemID, job.ParseTaskID, err)
|
|
return
|
|
}
|
|
if len(pointIDs) > 0 {
|
|
if err := s.store.DeletePoints(ctx, pointIDs); err != nil {
|
|
_ = s.markKnowledgeIngestionFailed(context.Background(), job.TenantID, job.ItemID, job.ParseTaskID, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
embeddings, err := s.embedChunks(ctx, chunks)
|
|
if err != nil {
|
|
_ = s.markKnowledgeIngestionFailed(context.Background(), job.TenantID, job.ItemID, job.ParseTaskID, err)
|
|
return
|
|
}
|
|
if len(embeddings) == 0 || len(embeddings[0]) == 0 {
|
|
_ = s.markKnowledgeIngestionFailed(context.Background(), job.TenantID, job.ItemID, job.ParseTaskID, errors.New("embedding vector is empty"))
|
|
return
|
|
}
|
|
|
|
if err := s.store.EnsureCollection(ctx, len(embeddings[0])); err != nil {
|
|
_ = s.markKnowledgeIngestionFailed(context.Background(), job.TenantID, job.ItemID, job.ParseTaskID, err)
|
|
return
|
|
}
|
|
|
|
insertedPointIDs := make([]string, 0, len(chunks))
|
|
defer func() {
|
|
if err != nil && len(insertedPointIDs) > 0 {
|
|
_ = s.store.DeletePoints(context.Background(), insertedPointIDs)
|
|
}
|
|
}()
|
|
|
|
points := make([]retrieval.VectorPoint, 0, len(chunks))
|
|
metas := make([]knowledgeChunkMetaInput, 0, len(chunks))
|
|
for index, chunk := range chunks {
|
|
pointID := uuid.NewString()
|
|
insertedPointIDs = append(insertedPointIDs, pointID)
|
|
points = append(points, retrieval.VectorPoint{
|
|
ID: pointID,
|
|
Vector: embeddings[index],
|
|
Payload: map[string]any{
|
|
"tenant_id": job.TenantID,
|
|
"group_id": item.GroupID,
|
|
"group_name": item.GroupName,
|
|
"item_id": item.ID,
|
|
"item_name": item.Name,
|
|
"chunk_index": index,
|
|
"item_version": item.ItemVersion,
|
|
"status": "active",
|
|
"source_type": item.SourceType,
|
|
"text": chunk,
|
|
},
|
|
})
|
|
metas = append(metas, knowledgeChunkMetaInput{
|
|
ChunkIndex: index,
|
|
TokenCount: estimateKnowledgeTokenCount(chunk),
|
|
QdrantPointID: pointID,
|
|
ItemVersion: item.ItemVersion,
|
|
Status: "active",
|
|
})
|
|
}
|
|
|
|
if err = s.store.Upsert(ctx, points); err != nil {
|
|
_ = s.markKnowledgeIngestionFailed(context.Background(), job.TenantID, job.ItemID, job.ParseTaskID, err)
|
|
return
|
|
}
|
|
|
|
if err = s.markKnowledgeIngestionCompleted(ctx, job.TenantID, job.ItemID, job.ParseTaskID, item.Name, parsedContent, metas); err != nil {
|
|
_ = s.markKnowledgeIngestionFailed(context.Background(), job.TenantID, job.ItemID, job.ParseTaskID, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
func (s *KnowledgeService) loadKnowledgeItemForParse(
|
|
ctx context.Context,
|
|
tenantID, itemID int64,
|
|
) (*knowledgeItemRecord, error) {
|
|
record := &knowledgeItemRecord{}
|
|
var contentText *string
|
|
var markdownContent *string
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT ki.id, ki.tenant_id, ki.group_id, kg.name, ki.name, ki.source_type,
|
|
ki.source_uri, ki.storage_key, ki.content_text, ki.markdown_content, ki.item_version
|
|
FROM knowledge_items ki
|
|
JOIN knowledge_groups kg ON kg.id = ki.group_id
|
|
WHERE ki.id = $1 AND ki.tenant_id = $2 AND ki.deleted_at IS NULL
|
|
`, itemID, tenantID).Scan(
|
|
&record.ID,
|
|
&record.TenantID,
|
|
&record.GroupID,
|
|
&record.GroupName,
|
|
&record.Name,
|
|
&record.SourceType,
|
|
&record.SourceURI,
|
|
&record.StorageKey,
|
|
&contentText,
|
|
&markdownContent,
|
|
&record.ItemVersion,
|
|
)
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
record.ContentText = contentText
|
|
record.Markdown = markdownContent
|
|
return record, nil
|
|
}
|
|
|
|
func (s *KnowledgeService) readKnowledgeItemParsedContent(ctx context.Context, item *knowledgeItemRecord) (*knowledgeParsedContent, error) {
|
|
if item == nil {
|
|
return nil, fmt.Errorf("knowledge item is empty")
|
|
}
|
|
|
|
switch item.SourceType {
|
|
case "text":
|
|
return parseKnowledgeTextInput(item.Name, derefKnowledgeString(item.ContentText)), nil
|
|
case "document":
|
|
data, err := s.objectStorage.GetBytes(ctx, item.StorageKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return extractKnowledgeContentFromFile(item.StorageKey, data)
|
|
case "website":
|
|
if item.SourceURI == nil || strings.TrimSpace(*item.SourceURI) == "" {
|
|
return nil, fmt.Errorf("knowledge website url is empty")
|
|
}
|
|
return s.parseWebsiteKnowledge(ctx, strings.TrimSpace(*item.SourceURI))
|
|
default:
|
|
return nil, fmt.Errorf("unsupported knowledge source type %q", item.SourceType)
|
|
}
|
|
}
|
|
|
|
type knowledgeChunkMetaInput struct {
|
|
ChunkIndex int
|
|
TokenCount int
|
|
QdrantPointID string
|
|
ItemVersion int
|
|
Status string
|
|
}
|
|
|
|
func (s *KnowledgeService) createPendingKnowledgeItem(
|
|
ctx context.Context,
|
|
tenantID int64,
|
|
input knowledgeIngestInput,
|
|
) (int64, int64, error) {
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return 0, 0, response.ErrInternal(50055, "knowledge_item_create_failed", "failed to begin knowledge transaction")
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
var itemID int64
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO knowledge_items (
|
|
tenant_id, group_id, source_type, name, source_uri, storage_key,
|
|
content_text, markdown_content, status, size_bytes, item_version
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, 'pending', $8, 1)
|
|
RETURNING id
|
|
`, tenantID, input.GroupID, input.SourceType, input.Name, input.SourceURI, input.StorageKey, input.ContentText, input.SizeBytes).Scan(&itemID)
|
|
if err != nil {
|
|
return 0, 0, response.ErrConflict(40951, "knowledge_item_create_failed", "failed to create knowledge item")
|
|
}
|
|
|
|
var taskID int64
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO knowledge_parse_tasks (
|
|
tenant_id, knowledge_item_id, source_type, status
|
|
)
|
|
VALUES ($1, $2, $3, 'pending')
|
|
RETURNING id
|
|
`, tenantID, itemID, input.SourceType).Scan(&taskID)
|
|
if err != nil {
|
|
return 0, 0, response.ErrInternal(50055, "knowledge_item_create_failed", "failed to create knowledge parse task")
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return 0, 0, response.ErrInternal(50055, "knowledge_item_create_failed", "failed to commit knowledge transaction")
|
|
}
|
|
return itemID, taskID, nil
|
|
}
|
|
|
|
func (s *KnowledgeService) markKnowledgeIngestionRunning(
|
|
ctx context.Context,
|
|
tenantID, itemID, parseTaskID int64,
|
|
) error {
|
|
if _, err := s.pool.Exec(ctx, `
|
|
UPDATE knowledge_items
|
|
SET status = 'processing', error_message = NULL, updated_at = NOW()
|
|
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
|
`, itemID, tenantID); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := s.pool.Exec(ctx, `
|
|
UPDATE knowledge_parse_tasks
|
|
SET status = 'running',
|
|
error_message = NULL,
|
|
started_at = COALESCE(started_at, NOW()),
|
|
updated_at = NOW()
|
|
WHERE id = $1 AND tenant_id = $2
|
|
`, parseTaskID, tenantID); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *KnowledgeService) markKnowledgeIngestionCompleted(
|
|
ctx context.Context,
|
|
tenantID, itemID, parseTaskID int64,
|
|
itemName string,
|
|
content *knowledgeParsedContent,
|
|
metas []knowledgeChunkMetaInput,
|
|
) error {
|
|
if content == nil {
|
|
return errors.New("knowledge content is empty")
|
|
}
|
|
if strings.TrimSpace(content.Text) == "" {
|
|
return errors.New("knowledge text content is empty")
|
|
}
|
|
if strings.TrimSpace(content.Markdown) == "" {
|
|
content.Markdown = buildKnowledgeMarkdownFromText("", content.Text)
|
|
}
|
|
itemName = strings.TrimSpace(itemName)
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
DELETE FROM knowledge_chunks_meta
|
|
WHERE tenant_id = $1 AND knowledge_item_id = $2
|
|
`, tenantID, itemID); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, meta := range metas {
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO knowledge_chunks_meta (
|
|
tenant_id, knowledge_item_id, chunk_index, token_count,
|
|
qdrant_point_id, item_version, status
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
`, tenantID, itemID, meta.ChunkIndex, meta.TokenCount, meta.QdrantPointID, meta.ItemVersion, meta.Status); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE knowledge_items
|
|
SET status = 'completed',
|
|
error_message = NULL,
|
|
name = CASE WHEN $3 <> '' THEN $3 ELSE name END,
|
|
content_text = $4,
|
|
markdown_content = $5,
|
|
updated_at = NOW()
|
|
WHERE id = $1 AND tenant_id = $2
|
|
`, itemID, tenantID, itemName, content.Text, content.Markdown); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE knowledge_parse_tasks
|
|
SET status = 'completed', error_message = NULL, completed_at = NOW(), updated_at = NOW()
|
|
WHERE id = $1 AND tenant_id = $2
|
|
`, parseTaskID, tenantID); err != nil {
|
|
return err
|
|
}
|
|
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
func (s *KnowledgeService) markKnowledgeIngestionFailed(
|
|
ctx context.Context,
|
|
tenantID, itemID, parseTaskID int64,
|
|
cause error,
|
|
) error {
|
|
errMessage := "knowledge ingestion failed"
|
|
if cause != nil {
|
|
errMessage = strings.TrimSpace(cause.Error())
|
|
}
|
|
|
|
if s.logger != nil {
|
|
s.logger.Error("knowledge parse failed",
|
|
zap.Int64("tenant_id", tenantID),
|
|
zap.Int64("knowledge_item_id", itemID),
|
|
zap.Int64("parse_task_id", parseTaskID),
|
|
zap.String("error_message", errMessage),
|
|
)
|
|
}
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
UPDATE knowledge_items
|
|
SET status = 'failed', error_message = $1, updated_at = NOW()
|
|
WHERE id = $2 AND tenant_id = $3
|
|
`, errMessage, itemID, tenantID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = s.pool.Exec(ctx, `
|
|
UPDATE knowledge_parse_tasks
|
|
SET status = 'failed', error_message = $1, completed_at = NOW(), updated_at = NOW()
|
|
WHERE id = $2 AND tenant_id = $3
|
|
`, errMessage, parseTaskID, tenantID)
|
|
return err
|
|
}
|
|
|
|
func buildKnowledgeObjectKey(tenantID int64, sourceType, fileName string) string {
|
|
fileName = sanitizeKnowledgeFileName(fileName)
|
|
if fileName == "" {
|
|
fileName = sourceType + ".bin"
|
|
}
|
|
return fmt.Sprintf(
|
|
"knowledge/%d/%s/%s/%s-%s",
|
|
tenantID,
|
|
sourceType,
|
|
time.Now().UTC().Format("2006/01/02"),
|
|
uuid.NewString(),
|
|
fileName,
|
|
)
|
|
}
|
|
|
|
func sanitizeKnowledgeFileName(fileName string) string {
|
|
fileName = filepath.Base(strings.TrimSpace(fileName))
|
|
replacer := strings.NewReplacer(
|
|
"/", "-",
|
|
"\\", "-",
|
|
":", "-",
|
|
"?", "-",
|
|
"&", "-",
|
|
"=", "-",
|
|
"#", "-",
|
|
)
|
|
return replacer.Replace(fileName)
|
|
}
|
|
|
|
func detectKnowledgeContentType(fileName string) string {
|
|
switch strings.ToLower(filepath.Ext(fileName)) {
|
|
case ".txt", ".md", ".markdown":
|
|
return "text/plain; charset=utf-8"
|
|
case ".html", ".htm":
|
|
return "text/html; charset=utf-8"
|
|
case ".pdf":
|
|
return "application/pdf"
|
|
case ".docx":
|
|
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
case ".xlsx":
|
|
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
case ".json":
|
|
return "application/json"
|
|
case ".csv":
|
|
return "text/csv; charset=utf-8"
|
|
default:
|
|
return "application/octet-stream"
|
|
}
|
|
}
|
|
|
|
func derefKnowledgeString(value *string) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(*value)
|
|
}
|
|
|
|
func (s *KnowledgeService) ensureGroupAccessible(ctx context.Context, tenantID, groupID int64) error {
|
|
var exists bool
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT EXISTS(
|
|
SELECT 1
|
|
FROM knowledge_groups
|
|
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
|
)
|
|
`, groupID, tenantID).Scan(&exists)
|
|
if err != nil {
|
|
return response.ErrInternal(50050, "knowledge_group_query_failed", "failed to query knowledge group")
|
|
}
|
|
if !exists {
|
|
return response.ErrNotFound(40451, "knowledge_group_not_found", "knowledge group not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *KnowledgeService) ensureRootGroup(ctx context.Context, tenantID, groupID int64) error {
|
|
if err := s.ensureGroupAccessible(ctx, tenantID, groupID); err != nil {
|
|
return err
|
|
}
|
|
|
|
var isRoot bool
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT EXISTS(
|
|
SELECT 1
|
|
FROM knowledge_groups
|
|
WHERE id = $1
|
|
AND tenant_id = $2
|
|
AND deleted_at IS NULL
|
|
AND parent_id IS NULL
|
|
)
|
|
`, groupID, tenantID).Scan(&isRoot)
|
|
if err != nil {
|
|
return response.ErrInternal(50050, "knowledge_group_query_failed", "failed to query knowledge group")
|
|
}
|
|
if !isRoot {
|
|
return response.ErrBadRequest(40050, "knowledge_group_depth_exceeded", "third-level knowledge groups are not supported")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *KnowledgeService) ensureStorableGroup(ctx context.Context, tenantID, groupID int64) error {
|
|
if err := s.ensureGroupAccessible(ctx, tenantID, groupID); err != nil {
|
|
return err
|
|
}
|
|
|
|
var storable bool
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT EXISTS(
|
|
SELECT 1
|
|
FROM knowledge_groups child
|
|
JOIN knowledge_groups parent
|
|
ON parent.id = child.parent_id
|
|
AND parent.tenant_id = child.tenant_id
|
|
AND parent.deleted_at IS NULL
|
|
WHERE child.id = $1
|
|
AND child.tenant_id = $2
|
|
AND child.deleted_at IS NULL
|
|
AND parent.parent_id IS NULL
|
|
)
|
|
`, groupID, tenantID).Scan(&storable)
|
|
if err != nil {
|
|
return response.ErrInternal(50050, "knowledge_group_query_failed", "failed to query knowledge group")
|
|
}
|
|
if !storable {
|
|
return response.ErrBadRequest(40053, "knowledge_group_storage_invalid", "knowledge items can only be stored in second-level groups")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *KnowledgeService) resolveKnowledgeStorageGroupID(ctx context.Context, tenantID, groupID int64) (int64, error) {
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return 0, response.ErrInternal(50055, "knowledge_group_resolve_failed", "failed to begin knowledge group transaction")
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
resolvedGroupID, err := s.resolveKnowledgeStorageGroupIDTx(ctx, tx, tenantID, groupID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return 0, response.ErrInternal(50055, "knowledge_group_resolve_failed", "failed to commit knowledge group transaction")
|
|
}
|
|
return resolvedGroupID, nil
|
|
}
|
|
|
|
func (s *KnowledgeService) resolveKnowledgeStorageGroupIDTx(
|
|
ctx context.Context,
|
|
tx pgx.Tx,
|
|
tenantID, groupID int64,
|
|
) (int64, error) {
|
|
var parentID *int64
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT parent_id
|
|
FROM knowledge_groups
|
|
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
|
FOR UPDATE
|
|
`, groupID, tenantID).Scan(&parentID); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return 0, response.ErrNotFound(40451, "knowledge_group_not_found", "knowledge group not found")
|
|
}
|
|
return 0, response.ErrInternal(50050, "knowledge_group_query_failed", "failed to query knowledge group")
|
|
}
|
|
|
|
if parentID != nil {
|
|
var grandParentID *int64
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT parent_id
|
|
FROM knowledge_groups
|
|
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
|
`, *parentID, tenantID).Scan(&grandParentID); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return 0, response.ErrNotFound(40451, "knowledge_group_not_found", "knowledge group not found")
|
|
}
|
|
return 0, response.ErrInternal(50050, "knowledge_group_query_failed", "failed to query knowledge group")
|
|
}
|
|
if grandParentID != nil {
|
|
return 0, response.ErrBadRequest(40053, "knowledge_group_storage_invalid", "knowledge items can only be stored in second-level groups")
|
|
}
|
|
return groupID, nil
|
|
}
|
|
|
|
var childGroupID int64
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT id
|
|
FROM knowledge_groups
|
|
WHERE tenant_id = $1
|
|
AND parent_id = $2
|
|
AND name = $3
|
|
AND deleted_at IS NULL
|
|
ORDER BY id
|
|
LIMIT 1
|
|
`, tenantID, groupID, defaultKnowledgeChildGroupName).Scan(&childGroupID); err == nil {
|
|
return childGroupID, nil
|
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
|
return 0, response.ErrInternal(50050, "knowledge_group_query_failed", "failed to query knowledge group")
|
|
}
|
|
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO knowledge_groups (tenant_id, name, parent_id, sort_order)
|
|
VALUES ($1, $2, $3, 0)
|
|
RETURNING id
|
|
`, tenantID, defaultKnowledgeChildGroupName, groupID).Scan(&childGroupID); err == nil {
|
|
return childGroupID, nil
|
|
}
|
|
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT id
|
|
FROM knowledge_groups
|
|
WHERE tenant_id = $1
|
|
AND parent_id = $2
|
|
AND name = $3
|
|
AND deleted_at IS NULL
|
|
ORDER BY id
|
|
LIMIT 1
|
|
`, tenantID, groupID, defaultKnowledgeChildGroupName).Scan(&childGroupID); err != nil {
|
|
return 0, response.ErrConflict(40950, "knowledge_group_create_failed", "failed to create default knowledge group")
|
|
}
|
|
return childGroupID, nil
|
|
}
|
|
|
|
func (s *KnowledgeService) listGroupSubtreeIDs(
|
|
ctx context.Context,
|
|
tx pgx.Tx,
|
|
tenantID, groupID int64,
|
|
) ([]int64, error) {
|
|
rows, err := tx.Query(ctx, `
|
|
WITH RECURSIVE selected_groups AS (
|
|
SELECT id
|
|
FROM knowledge_groups
|
|
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
|
UNION ALL
|
|
SELECT child.id
|
|
FROM knowledge_groups child
|
|
JOIN selected_groups parent ON child.parent_id = parent.id
|
|
WHERE child.tenant_id = $2 AND child.deleted_at IS NULL
|
|
)
|
|
SELECT id
|
|
FROM selected_groups
|
|
`, groupID, tenantID)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50054, "knowledge_group_delete_failed", "failed to query knowledge groups")
|
|
}
|
|
defer rows.Close()
|
|
|
|
groupIDs := make([]int64, 0)
|
|
for rows.Next() {
|
|
var id int64
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, response.ErrInternal(50054, "knowledge_group_delete_failed", err.Error())
|
|
}
|
|
groupIDs = append(groupIDs, id)
|
|
}
|
|
return groupIDs, nil
|
|
}
|
|
|
|
func (s *KnowledgeService) listActiveKnowledgeItemIDs(
|
|
ctx context.Context,
|
|
tx pgx.Tx,
|
|
tenantID int64,
|
|
groupIDs []int64,
|
|
) ([]int64, error) {
|
|
if len(groupIDs) == 0 {
|
|
return []int64{}, nil
|
|
}
|
|
|
|
rows, err := tx.Query(ctx, `
|
|
SELECT id
|
|
FROM knowledge_items
|
|
WHERE tenant_id = $1
|
|
AND deleted_at IS NULL
|
|
AND group_id = ANY($2::bigint[])
|
|
`, tenantID, groupIDs)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50054, "knowledge_group_delete_failed", "failed to query knowledge items")
|
|
}
|
|
defer rows.Close()
|
|
|
|
itemIDs := make([]int64, 0)
|
|
for rows.Next() {
|
|
var id int64
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, response.ErrInternal(50054, "knowledge_group_delete_failed", err.Error())
|
|
}
|
|
itemIDs = append(itemIDs, id)
|
|
}
|
|
return itemIDs, nil
|
|
}
|
|
|
|
func (s *KnowledgeService) embedChunks(ctx context.Context, chunks []string) ([][]float64, error) {
|
|
results := make([][]float64, 0, len(chunks))
|
|
for start := 0; start < len(chunks); start += s.embeddingBatchSize {
|
|
end := start + s.embeddingBatchSize
|
|
if end > len(chunks) {
|
|
end = len(chunks)
|
|
}
|
|
vectors, err := s.provider.Embed(ctx, chunks[start:end])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
results = append(results, vectors...)
|
|
}
|
|
if len(results) != len(chunks) {
|
|
return nil, errors.New("embedding result count mismatch")
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
func (s *KnowledgeService) listKnowledgePointIDs(ctx context.Context, tenantID, itemID int64) ([]string, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT qdrant_point_id
|
|
FROM knowledge_chunks_meta
|
|
WHERE tenant_id = $1 AND knowledge_item_id = $2 AND status <> 'deleted'
|
|
`, tenantID, itemID)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50054, "knowledge_item_delete_failed", "failed to query knowledge item points")
|
|
}
|
|
defer rows.Close()
|
|
|
|
pointIDs := make([]string, 0)
|
|
for rows.Next() {
|
|
var pointID string
|
|
if err := rows.Scan(&pointID); err != nil {
|
|
return nil, response.ErrInternal(50054, "knowledge_item_delete_failed", err.Error())
|
|
}
|
|
if strings.TrimSpace(pointID) != "" {
|
|
pointIDs = append(pointIDs, pointID)
|
|
}
|
|
}
|
|
return pointIDs, nil
|
|
}
|
|
|
|
func (s *KnowledgeService) filterActiveKnowledgeSnippets(
|
|
ctx context.Context,
|
|
tenantID int64,
|
|
candidates []KnowledgeSnippet,
|
|
) ([]KnowledgeSnippet, error) {
|
|
if len(candidates) == 0 {
|
|
return []KnowledgeSnippet{}, nil
|
|
}
|
|
|
|
itemIDs := make([]int64, 0, len(candidates))
|
|
for _, candidate := range candidates {
|
|
if candidate.KnowledgeItemID > 0 {
|
|
itemIDs = append(itemIDs, candidate.KnowledgeItemID)
|
|
}
|
|
}
|
|
itemIDs = normalizeInt64IDs(itemIDs)
|
|
if len(itemIDs) == 0 {
|
|
return []KnowledgeSnippet{}, nil
|
|
}
|
|
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id
|
|
FROM knowledge_items
|
|
WHERE tenant_id = $1
|
|
AND id = ANY($2::bigint[])
|
|
AND deleted_at IS NULL
|
|
`, tenantID, itemIDs)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50051, "knowledge_items_query_failed", "failed to filter deleted knowledge items")
|
|
}
|
|
defer rows.Close()
|
|
|
|
active := make(map[int64]struct{}, len(itemIDs))
|
|
for rows.Next() {
|
|
var itemID int64
|
|
if err := rows.Scan(&itemID); err != nil {
|
|
return nil, response.ErrInternal(50051, "knowledge_items_scan_failed", err.Error())
|
|
}
|
|
active[itemID] = struct{}{}
|
|
}
|
|
|
|
filtered := make([]KnowledgeSnippet, 0, len(candidates))
|
|
for _, candidate := range candidates {
|
|
if _, ok := active[candidate.KnowledgeItemID]; ok {
|
|
filtered = append(filtered, candidate)
|
|
}
|
|
}
|
|
return filtered, nil
|
|
}
|
|
|
|
func (s *KnowledgeService) cleanupDeletedKnowledgeItemAsync(tenantID, itemID int64) {
|
|
if !s.startCleanupJob(tenantID, itemID) {
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
defer s.finishCleanupJob(tenantID, itemID)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
s.cleanupDeletedKnowledgeItem(ctx, tenantID, itemID)
|
|
}()
|
|
}
|
|
|
|
func (s *KnowledgeService) runDeletedCleanupWorker() {
|
|
s.scheduleDeletedKnowledgeCleanup()
|
|
|
|
ticker := time.NewTicker(defaultKnowledgeCleanupPoll)
|
|
defer ticker.Stop()
|
|
|
|
for range ticker.C {
|
|
s.scheduleDeletedKnowledgeCleanup()
|
|
}
|
|
}
|
|
|
|
func (s *KnowledgeService) scheduleDeletedKnowledgeCleanup() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
jobs, err := s.listKnowledgeCleanupCandidates(ctx, 20)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
for _, job := range jobs {
|
|
s.cleanupDeletedKnowledgeItemAsync(job.TenantID, job.ItemID)
|
|
}
|
|
}
|
|
|
|
func (s *KnowledgeService) listKnowledgeCleanupCandidates(
|
|
ctx context.Context,
|
|
limit int,
|
|
) ([]knowledgeCleanupJob, error) {
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT ki.tenant_id, ki.id
|
|
FROM knowledge_items ki
|
|
WHERE ki.deleted_at IS NOT NULL
|
|
ORDER BY ki.updated_at ASC, ki.id ASC
|
|
LIMIT $1
|
|
`, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
jobs := make([]knowledgeCleanupJob, 0, limit)
|
|
for rows.Next() {
|
|
var job knowledgeCleanupJob
|
|
if err := rows.Scan(&job.TenantID, &job.ItemID); err != nil {
|
|
return nil, err
|
|
}
|
|
jobs = append(jobs, job)
|
|
}
|
|
return jobs, nil
|
|
}
|
|
|
|
func (s *KnowledgeService) startCleanupJob(tenantID, itemID int64) bool {
|
|
key := fmt.Sprintf("%d:%d", tenantID, itemID)
|
|
|
|
s.cleanupMu.Lock()
|
|
defer s.cleanupMu.Unlock()
|
|
|
|
if _, exists := s.cleanupInFlight[key]; exists {
|
|
return false
|
|
}
|
|
s.cleanupInFlight[key] = struct{}{}
|
|
return true
|
|
}
|
|
|
|
func (s *KnowledgeService) finishCleanupJob(tenantID, itemID int64) {
|
|
key := fmt.Sprintf("%d:%d", tenantID, itemID)
|
|
|
|
s.cleanupMu.Lock()
|
|
delete(s.cleanupInFlight, key)
|
|
s.cleanupMu.Unlock()
|
|
}
|
|
|
|
func (s *KnowledgeService) cleanupDeletedKnowledgeItem(ctx context.Context, tenantID, itemID int64) {
|
|
item, err := s.loadKnowledgeItemForCleanup(ctx, tenantID, itemID)
|
|
if err != nil || item == nil {
|
|
return
|
|
}
|
|
|
|
errs := make([]string, 0, 2)
|
|
|
|
if strings.TrimSpace(item.StorageKey) != "" {
|
|
if err := s.objectStorage.Delete(ctx, item.StorageKey); err != nil {
|
|
errs = append(errs, err.Error())
|
|
} else {
|
|
_ = s.markKnowledgeStorageDeleted(ctx, tenantID, itemID)
|
|
}
|
|
}
|
|
|
|
pointIDs, err := s.listKnowledgePointIDs(ctx, tenantID, itemID)
|
|
if err != nil {
|
|
errs = append(errs, err.Error())
|
|
} else {
|
|
if len(pointIDs) > 0 {
|
|
if err := s.store.DeletePoints(ctx, pointIDs); err != nil {
|
|
errs = append(errs, err.Error())
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
if s.logger != nil {
|
|
s.logger.Error("knowledge cleanup failed",
|
|
zap.Int64("tenant_id", tenantID),
|
|
zap.Int64("knowledge_item_id", itemID),
|
|
zap.Strings("errors", errs),
|
|
)
|
|
}
|
|
_ = s.setKnowledgeCleanupError(ctx, tenantID, itemID, strings.Join(errs, "; "))
|
|
return
|
|
}
|
|
|
|
if err := s.deleteKnowledgeItemPermanently(ctx, tenantID, itemID); err != nil {
|
|
if s.logger != nil {
|
|
s.logger.Error("knowledge cleanup finalize failed",
|
|
zap.Int64("tenant_id", tenantID),
|
|
zap.Int64("knowledge_item_id", itemID),
|
|
zap.Error(err),
|
|
)
|
|
}
|
|
_ = s.setKnowledgeCleanupError(ctx, tenantID, itemID, err.Error())
|
|
return
|
|
}
|
|
|
|
if s.logger != nil {
|
|
s.logger.Info("knowledge cleanup completed",
|
|
zap.Int64("tenant_id", tenantID),
|
|
zap.Int64("knowledge_item_id", itemID),
|
|
)
|
|
}
|
|
_ = s.clearKnowledgeCleanupError(ctx, tenantID, itemID)
|
|
}
|
|
|
|
func (s *KnowledgeService) loadKnowledgeItemForCleanup(
|
|
ctx context.Context,
|
|
tenantID, itemID int64,
|
|
) (*knowledgeItemRecord, error) {
|
|
record := &knowledgeItemRecord{}
|
|
var contentText *string
|
|
var markdownContent *string
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT ki.id, ki.tenant_id, ki.group_id, kg.name, ki.name, ki.source_type,
|
|
ki.source_uri, ki.storage_key, ki.content_text, ki.markdown_content, ki.item_version
|
|
FROM knowledge_items ki
|
|
JOIN knowledge_groups kg ON kg.id = ki.group_id
|
|
WHERE ki.id = $1 AND ki.tenant_id = $2 AND ki.deleted_at IS NOT NULL
|
|
`, itemID, tenantID).Scan(
|
|
&record.ID,
|
|
&record.TenantID,
|
|
&record.GroupID,
|
|
&record.GroupName,
|
|
&record.Name,
|
|
&record.SourceType,
|
|
&record.SourceURI,
|
|
&record.StorageKey,
|
|
&contentText,
|
|
&markdownContent,
|
|
&record.ItemVersion,
|
|
)
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
record.ContentText = contentText
|
|
record.Markdown = markdownContent
|
|
return record, nil
|
|
}
|
|
|
|
func (s *KnowledgeService) markKnowledgeStorageDeleted(ctx context.Context, tenantID, itemID int64) error {
|
|
_, err := s.pool.Exec(ctx, `
|
|
UPDATE knowledge_items
|
|
SET storage_key = '', updated_at = NOW()
|
|
WHERE id = $1 AND tenant_id = $2
|
|
`, itemID, tenantID)
|
|
return err
|
|
}
|
|
|
|
func (s *KnowledgeService) deleteKnowledgeItemPermanently(ctx context.Context, tenantID, itemID int64) error {
|
|
_, err := s.pool.Exec(ctx, `
|
|
DELETE FROM knowledge_items
|
|
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NOT NULL
|
|
`, itemID, tenantID)
|
|
return err
|
|
}
|
|
|
|
func (s *KnowledgeService) markKnowledgeChunkMetaDeleted(ctx context.Context, tenantID, itemID int64) error {
|
|
_, err := s.pool.Exec(ctx, `
|
|
UPDATE knowledge_chunks_meta
|
|
SET status = 'deleted', updated_at = NOW()
|
|
WHERE knowledge_item_id = $1 AND tenant_id = $2 AND status <> 'deleted'
|
|
`, itemID, tenantID)
|
|
return err
|
|
}
|
|
|
|
func (s *KnowledgeService) setKnowledgeCleanupError(ctx context.Context, tenantID, itemID int64, message string) error {
|
|
_, err := s.pool.Exec(ctx, `
|
|
UPDATE knowledge_items
|
|
SET error_message = $1, updated_at = NOW()
|
|
WHERE id = $2 AND tenant_id = $3
|
|
`, strings.TrimSpace(message), itemID, tenantID)
|
|
return err
|
|
}
|
|
|
|
func (s *KnowledgeService) clearKnowledgeCleanupError(ctx context.Context, tenantID, itemID int64) error {
|
|
_, err := s.pool.Exec(ctx, `
|
|
UPDATE knowledge_items
|
|
SET error_message = NULL, updated_at = NOW()
|
|
WHERE id = $1 AND tenant_id = $2
|
|
`, itemID, tenantID)
|
|
return err
|
|
}
|
|
|
|
func (s *KnowledgeService) listKnowledgeItemsByIDs(ctx context.Context, tenantID int64, itemIDs []int64) ([]KnowledgeItemResponse, error) {
|
|
if len(itemIDs) == 0 {
|
|
return []KnowledgeItemResponse{}, nil
|
|
}
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT ki.id, ki.group_id, kg.name, ki.name, ki.source_type, ki.source_uri, ki.status,
|
|
ki.size_bytes, ki.error_message, ki.created_at, ki.updated_at
|
|
FROM knowledge_items ki
|
|
JOIN knowledge_groups kg ON kg.id = ki.group_id
|
|
WHERE ki.tenant_id = $1 AND ki.id = ANY($2::bigint[]) AND ki.deleted_at IS NULL
|
|
ORDER BY ki.created_at DESC, ki.id DESC
|
|
`, tenantID, itemIDs)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50051, "knowledge_items_query_failed", "failed to list knowledge items")
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := make([]KnowledgeItemResponse, 0)
|
|
for rows.Next() {
|
|
var (
|
|
item KnowledgeItemResponse
|
|
sourceURI *string
|
|
errorMessage *string
|
|
createdAt, updatedAt time.Time
|
|
)
|
|
if err := rows.Scan(
|
|
&item.ID,
|
|
&item.GroupID,
|
|
&item.GroupName,
|
|
&item.Name,
|
|
&item.SourceType,
|
|
&sourceURI,
|
|
&item.Status,
|
|
&item.SizeBytes,
|
|
&errorMessage,
|
|
&createdAt,
|
|
&updatedAt,
|
|
); err != nil {
|
|
return nil, response.ErrInternal(50051, "knowledge_items_scan_failed", err.Error())
|
|
}
|
|
item.SourceURI = sourceURI
|
|
item.ErrorMessage = errorMessage
|
|
item.CreatedAt = createdAt.Format(time.RFC3339)
|
|
item.UpdatedAt = updatedAt.Format(time.RFC3339)
|
|
items = append(items, item)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (s *KnowledgeService) listGroupNames(ctx context.Context, tenantID int64, groupIDs []int64) ([]string, error) {
|
|
groupIDs = normalizeInt64IDs(groupIDs)
|
|
if len(groupIDs) == 0 {
|
|
return []string{}, nil
|
|
}
|
|
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, name
|
|
FROM knowledge_groups
|
|
WHERE tenant_id = $1 AND id = ANY($2::bigint[]) AND deleted_at IS NULL
|
|
ORDER BY sort_order, created_at, id
|
|
`, tenantID, groupIDs)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50050, "knowledge_group_query_failed", "failed to query knowledge groups")
|
|
}
|
|
defer rows.Close()
|
|
|
|
names := make([]string, 0, len(groupIDs))
|
|
for rows.Next() {
|
|
var (
|
|
groupID int64
|
|
name string
|
|
)
|
|
if err := rows.Scan(&groupID, &name); err != nil {
|
|
return nil, response.ErrInternal(50050, "knowledge_group_query_failed", err.Error())
|
|
}
|
|
if strings.TrimSpace(name) != "" {
|
|
names = append(names, strings.TrimSpace(name))
|
|
}
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
func normalizeInt64IDs(ids []int64) []int64 {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
set := make(map[int64]struct{}, len(ids))
|
|
result := make([]int64, 0, len(ids))
|
|
for _, id := range ids {
|
|
if id <= 0 {
|
|
continue
|
|
}
|
|
if _, exists := set[id]; exists {
|
|
continue
|
|
}
|
|
set[id] = struct{}{}
|
|
result = append(result, id)
|
|
}
|
|
slices.Sort(result)
|
|
return result
|
|
}
|
|
|
|
func anyString(value any) string {
|
|
switch typed := value.(type) {
|
|
case string:
|
|
return typed
|
|
default:
|
|
return fmt.Sprintf("%v", typed)
|
|
}
|
|
}
|
|
|
|
func anyInt64(value any) int64 {
|
|
switch typed := value.(type) {
|
|
case int64:
|
|
return typed
|
|
case int32:
|
|
return int64(typed)
|
|
case int:
|
|
return int64(typed)
|
|
case float64:
|
|
return int64(typed)
|
|
case float32:
|
|
return int64(typed)
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func minInt(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|