package app import ( "context" "errors" "fmt" "net/http" "net/url" "path/filepath" "runtime/debug" "slices" "strings" "sync" "time" "unicode/utf8" "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 defaultKnowledgeChildGroupName = "默认目录" maxKnowledgeSnippetsPerItem = 2 maxKnowledgeTextNameRunes = 20 ) type KnowledgeService struct { pool *pgxpool.Pool provider retrieval.Provider store retrieval.VectorStore objectStorage objectstorage.Client llmClient llm.Client logger *zap.Logger cfg knowledgeRuntimeConfig configProvider runtimeConfigProvider httpClient *http.Client jobs chan knowledgeParseJob cleanupMu sync.Mutex cleanupInFlight map[string]struct{} browserFetchMu sync.Mutex browserFetchQ *browserFetchQueue } type knowledgeParseJob struct { TenantID int64 ItemID int64 ParseTaskID int64 } type knowledgeItemRecord struct { ID int64 TenantID int64 BrandID 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 PreciseFacts []string FactConstraints []KnowledgeFactConstraint FactConflicts []KnowledgeFactConflict } type KnowledgeSnippet struct { PointID string GroupID int64 GroupName string KnowledgeItemID int64 ItemName string SourceType string SourceURI *string Text string SearchText string Score float64 PreciseFacts []string } 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 { svc := &KnowledgeService{ pool: pool, provider: provider, store: store, objectStorage: objectStorage, llmClient: llmClient, logger: logger, cfg: knowledgeRuntimeFromConfig(cfg, llmCfg), httpClient: &http.Client{Timeout: defaultKnowledgeURLParseTimeout}, cleanupInFlight: make(map[string]struct{}), } 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() } return svc } func (s *KnowledgeService) WithConfigProvider(provider runtimeConfigProvider) *KnowledgeService { s.configProvider = provider return s } func (s *KnowledgeService) runtimeConfig() knowledgeRuntimeConfig { if s != nil && s.configProvider != nil { if cfg := s.configProvider.Current(); cfg != nil { return knowledgeRuntimeFromFullConfig(cfg) } } if s == nil { return knowledgeRuntimeFromConfig(config.RetrievalConfig{}, config.LLMConfig{}) } return s.cfg } func (s *KnowledgeService) ListGroups(ctx context.Context) ([]KnowledgeGroupResponse, error) { actor := auth.MustActor(ctx) brandID, err := requireCurrentBrandID(ctx) if err != nil { return nil, err } rows, err := s.pool.Query(ctx, ` SELECT id, name, parent_id, sort_order, created_at FROM knowledge_groups WHERE tenant_id = $1 AND brand_id = $2 AND deleted_at IS NULL ORDER BY sort_order, created_at, id `, actor.TenantID, brandID) 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 ki.group_id, COUNT(DISTINCT ki.id)::bigint FROM knowledge_items ki WHERE ki.tenant_id = $1 AND ki.brand_id = $2 AND ki.deleted_at IS NULL AND ki.status = 'completed' AND EXISTS ( SELECT 1 FROM knowledge_chunks_meta kcm WHERE kcm.tenant_id = ki.tenant_id AND kcm.knowledge_item_id = ki.id AND kcm.item_version = ki.item_version AND kcm.status = 'active' ) GROUP BY ki.group_id `, actor.TenantID, brandID) 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) brandID, err := requireCurrentBrandID(ctx) if err != nil { return nil, err } 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, brandID, *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, brand_id, name, parent_id, sort_order) VALUES ($1, $2, $3, $4, $5) RETURNING id, created_at `, actor.TenantID, brandID, 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) brandID, err := requireCurrentBrandID(ctx) if err != nil { return err } 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 brand_id = $5 AND deleted_at IS NULL `, name, sortOrder, groupID, actor.TenantID, brandID) 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) brandID, err := requireCurrentBrandID(ctx) if err != nil { return err } 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, brandID, 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, brandID, 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 brand_id = $2 AND deleted_at IS NULL AND id = ANY($3::bigint[]) `, actor.TenantID, brandID, 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 brand_id = $2 AND deleted_at IS NULL AND id = ANY($3::bigint[]) `, actor.TenantID, brandID, groupIDs); err != nil { return response.ErrInternal(50054, "knowledge_group_delete_failed", "failed to delete knowledge groups") } if err := s.enqueueDeletedKnowledgeCleanupEvents(ctx, tx, actor.TenantID, itemIDs); err != nil { return response.ErrInternal(50054, "knowledge_group_delete_failed", "failed to enqueue knowledge cleanup") } if err := tx.Commit(ctx); err != nil { return response.ErrInternal(50054, "knowledge_group_delete_failed", "failed to commit delete transaction") } 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) brandID, err := requireCurrentBrandID(ctx) if err != nil { return nil, err } if groupID != nil && *groupID > 0 { if err := s.ensureGroupAccessible(ctx, actor.TenantID, brandID, *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.brand_id = ki.brand_id AND kg.deleted_at IS NULL WHERE ki.tenant_id = $1 AND ki.brand_id = $2 AND ki.deleted_at IS NULL ` args := []any{actor.TenantID, brandID} if groupID != nil && *groupID > 0 { query += ` AND ki.group_id IN ( WITH RECURSIVE selected_groups AS ( SELECT id FROM knowledge_groups WHERE id = $3 AND tenant_id = $1 AND brand_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 = $1 AND child.brand_id = $2 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) brandID, err := requireCurrentBrandID(ctx) if err != nil { return nil, err } 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.brand_id = ki.brand_id AND kg.deleted_at IS NULL WHERE ki.id = $1 AND ki.tenant_id = $2 AND ki.brand_id = $3 AND ki.deleted_at IS NULL `, itemID, actor.TenantID, brandID).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) brandID, err := requireCurrentBrandID(ctx) if err != nil { return nil, err } 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") } name := strings.TrimSpace(req.Name) if utf8.RuneCountInString(name) > maxKnowledgeTextNameRunes { return nil, response.ErrBadRequest(40055, "knowledge_text_name_too_long", "knowledge text name must be at most 20 characters") } return s.queueKnowledgeItem(ctx, actor.TenantID, brandID, knowledgeIngestInput{ GroupID: req.GroupID, Name: 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) brandID, err := requireCurrentBrandID(ctx) if err != nil { return nil, err } 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, brandID, 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) brandID, err := requireCurrentBrandID(ctx) if err != nil { return nil, err } 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, brandID, 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) brandID, err := requireCurrentBrandID(ctx) if err != nil { return err } 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 brand_id = $3 AND deleted_at IS NULL `, itemID, actor.TenantID, brandID) 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 := s.enqueueDeletedKnowledgeCleanupEvents(ctx, tx, actor.TenantID, []int64{itemID}); err != nil { return response.ErrInternal(50054, "knowledge_item_delete_failed", "failed to enqueue knowledge cleanup") } 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), ) } return nil } func (s *KnowledgeService) RetryItem(ctx context.Context, itemID int64) (*KnowledgeItemResponse, error) { actor := auth.MustActor(ctx) brandID, err := requireCurrentBrandID(ctx) if err != nil { return nil, err } var sourceType string var status string err = s.pool.QueryRow(ctx, ` SELECT source_type, status FROM knowledge_items WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL `, itemID, actor.TenantID, brandID).Scan(&sourceType, &status) 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") } if status != "failed" { return nil, response.ErrConflict(40952, "knowledge_item_retry_unavailable", "only failed knowledge items can be retried") } if err := s.validateIngestionDependencies(sourceType); err != nil { return nil, err } tx, err := s.pool.Begin(ctx) if err != nil { return nil, response.ErrInternal(50055, "knowledge_item_retry_failed", "failed to begin retry transaction") } defer func() { _ = tx.Rollback(ctx) }() var parseTaskID int64 err = tx.QueryRow(ctx, ` UPDATE knowledge_parse_tasks SET status = 'pending', error_message = NULL, started_at = NULL, completed_at = NULL, updated_at = NOW() WHERE id = ( SELECT id FROM knowledge_parse_tasks WHERE tenant_id = $1 AND knowledge_item_id = $2 ORDER BY created_at DESC, id DESC LIMIT 1 ) RETURNING id `, actor.TenantID, itemID).Scan(&parseTaskID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, response.ErrConflict(40952, "knowledge_item_retry_unavailable", "knowledge parse task is unavailable") } return nil, response.ErrInternal(50055, "knowledge_item_retry_failed", "failed to reset knowledge parse task") } tag, err := tx.Exec(ctx, ` UPDATE knowledge_items SET status = 'pending', error_message = NULL, updated_at = NOW() WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL AND status = 'failed' `, itemID, actor.TenantID, brandID) if err != nil { return nil, response.ErrInternal(50055, "knowledge_item_retry_failed", "failed to reset knowledge item") } if tag.RowsAffected() == 0 { return nil, response.ErrConflict(40952, "knowledge_item_retry_unavailable", "only failed knowledge items can be retried") } if err := tx.Commit(ctx); err != nil { return nil, response.ErrInternal(50055, "knowledge_item_retry_failed", "failed to commit retry transaction") } if err := s.enqueueParseJob(knowledgeParseJob{ TenantID: actor.TenantID, ItemID: itemID, ParseTaskID: parseTaskID, }); err != nil { _ = s.markKnowledgeIngestionFailed(context.Background(), actor.TenantID, itemID, parseTaskID, err) return nil, response.ErrServiceUnavailable(50356, "knowledge_queue_unavailable", "knowledge parse queue is busy, please retry") } items, err := s.listKnowledgeItemsByIDs(ctx, actor.TenantID, brandID, []int64{itemID}) if err != nil { return nil, err } if len(items) == 0 { return nil, response.ErrNotFound(40450, "knowledge_item_not_found", "knowledge item not found") } return &items[0], nil } func (s *KnowledgeService) ResolveContext( ctx context.Context, tenantID int64, brandID int64, groupIDs []int64, query string, ) (*KnowledgeContext, error) { requestedGroupIDs := normalizeInt64IDs(groupIDs) if len(requestedGroupIDs) == 0 { return nil, nil } queries := normalizeKnowledgeResolveQueries(query) if len(queries) == 0 { return nil, nil } queryRewriteStage := "fallback" queryRewriteModel := strings.TrimSpace(s.runtimeConfig().URLMarkdownModel) if rewritten, err := s.rewriteKnowledgeResolveQueries(ctx, query); err != nil { if s.logger != nil { s.logger.Warn("knowledge query rewrite failed", zap.String("query", knowledgeLogPreview(query, 600)), zap.String("query_rewrite_model", queryRewriteModel), zap.Strings("fallback_queries", queries), zap.Error(err), ) } } else if len(rewritten) >= maxKnowledgeQueryQuestions { queries = rewritten queryRewriteStage = "model" } else if len(rewritten) > 0 && s.logger != nil { s.logger.Warn("knowledge query rewrite returned insufficient questions", zap.String("query", knowledgeLogPreview(query, 600)), zap.String("query_rewrite_model", queryRewriteModel), zap.Strings("model_queries", rewritten), zap.Int("model_query_count", len(rewritten)), zap.Strings("fallback_queries", queries), ) } rerankQuery := strings.Join(queries, "\n") 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()) } searchGroupIDs, groupNames, err := s.resolveKnowledgeSearchScope(ctx, tenantID, brandID, requestedGroupIDs) if err != nil { return nil, err } if len(searchGroupIDs) == 0 || len(groupNames) == 0 { return nil, nil } baseContext := &KnowledgeContext{ GroupIDs: searchGroupIDs, GroupNames: groupNames, Snippets: []KnowledgeSnippet{}, PreciseFacts: s.collectKnowledgeContextPreciseFacts(ctx, tenantID, brandID, searchGroupIDs, nil), } s.applyKnowledgeFactConstraints(baseContext) vectors, err := s.provider.Embed(ctx, queries) if err != nil { return nil, response.ErrServiceUnavailable(50353, "embedding_failed", err.Error()) } if len(vectors) != len(queries) { return nil, response.ErrServiceUnavailable(50353, "embedding_failed", "embedding result count mismatch") } runtimeCfg := s.runtimeConfig() searchPoints := make([]retrieval.SearchPoint, 0, len(queries)*runtimeCfg.RecallLimit) for index, vector := range vectors { if len(vector) == 0 { return nil, response.ErrServiceUnavailable(50353, "embedding_failed", fmt.Sprintf("embedding vector is empty at query index %d", index)) } points, err := s.store.Search(ctx, retrieval.SearchRequest{ Vector: vector, TenantID: tenantID, BrandID: brandID, GroupIDs: searchGroupIDs, Limit: runtimeCfg.RecallLimit, }) if err != nil { return nil, response.ErrServiceUnavailable(50354, "qdrant_search_failed", err.Error()) } searchPoints = append(searchPoints, points...) } if len(searchPoints) == 0 { s.logKnowledgeResolve(baseContext, tenantID, rerankQuery, "no_search_points", queryRewriteStage, queryRewriteModel, nil, nil) return baseContext, nil } candidates := dedupeKnowledgeSearchCandidates(knowledgeSnippetsFromSearchPoints(searchPoints)) if len(candidates) == 0 { s.logKnowledgeResolve(baseContext, tenantID, rerankQuery, "no_candidate_text", queryRewriteStage, queryRewriteModel, nil, nil) return baseContext, nil } candidates, err = s.filterActiveKnowledgeSnippets(ctx, tenantID, brandID, candidates) if err != nil { return nil, err } if len(candidates) == 0 { s.logKnowledgeResolve(baseContext, tenantID, rerankQuery, "no_active_candidates", queryRewriteStage, queryRewriteModel, nil, nil) return baseContext, nil } documents := make([]string, 0, len(candidates)) for _, candidate := range candidates { documents = append(documents, candidate.RerankText()) } reranked, err := s.provider.Rerank(ctx, rerankQuery, documents, minInt(runtimeCfg.RerankTopN, len(documents))) if err != nil { selected := selectKnowledgeCandidateSnippets(candidates, runtimeCfg.RerankTopN) selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, brandID, selected) baseContext.Snippets = selected baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, brandID, searchGroupIDs, selected) s.applyKnowledgeFactConstraints(baseContext) s.logKnowledgeResolve(baseContext, tenantID, rerankQuery, "rerank_failed_fallback", queryRewriteStage, queryRewriteModel, candidates, selected) return baseContext, nil } selected := selectKnowledgeRerankedSnippets(candidates, reranked, runtimeCfg.RerankTopN) selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, brandID, selected) baseContext.Snippets = selected baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, brandID, searchGroupIDs, selected) s.applyKnowledgeFactConstraints(baseContext) s.logKnowledgeResolve(baseContext, tenantID, rerankQuery, "resolved", queryRewriteStage, queryRewriteModel, candidates, selected) return baseContext, nil } func (s *KnowledgeService) rewriteKnowledgeResolveQueries(ctx context.Context, query string) ([]string, error) { if s == nil || s.llmClient == nil { return []string{}, nil } prompt := buildKnowledgeQueryRewritePrompt(query) if prompt == "" { return []string{}, nil } runtimeCfg := s.runtimeConfig() result, err := s.llmClient.Generate(ctx, llm.GenerateRequest{ Model: runtimeCfg.URLMarkdownModel, Prompt: prompt, Timeout: 15 * time.Second, MaxOutputTokens: 600, ResponseFormat: &llm.ResponseFormat{ Type: llm.ResponseFormatTypeJSONObject, }, }, nil) if err != nil { return nil, err } return parseKnowledgeQueryRewriteOutput(result.Content), nil } func (s *KnowledgeService) logKnowledgeResolve( knowledgeCtx *KnowledgeContext, tenantID int64, query string, stage string, queryRewriteStage string, queryRewriteModel string, candidates []KnowledgeSnippet, selected []KnowledgeSnippet, ) { if s == nil || s.logger == nil || knowledgeCtx == nil { return } queries := normalizeKnowledgeResolveQueries(query) s.logger.Info("knowledge resolve result", zap.String("stage", strings.TrimSpace(stage)), zap.Int64("tenant_id", tenantID), zap.Int64s("group_ids", knowledgeCtx.GroupIDs), zap.Strings("group_names", knowledgeCtx.GroupNames), zap.String("query", knowledgeLogPreview(query, 600)), zap.Strings("queries", queries), zap.Int("query_count", len(queries)), zap.String("query_rewrite_stage", strings.TrimSpace(queryRewriteStage)), zap.String("query_rewrite_model", strings.TrimSpace(queryRewriteModel)), zap.Int("candidate_count", len(candidates)), zap.Int("selected_count", len(selected)), zap.Int("precise_fact_count", len(knowledgeCtx.PreciseFacts)), // zap.Strings("precise_facts", summarizeKnowledgeFactsForLog(knowledgeCtx.PreciseFacts)), ) } func normalizeKnowledgeResolveQueries(query string) []string { lines := strings.Split(strings.TrimSpace(query), "\n") queries := make([]string, 0, minInt(maxKnowledgeQueryQuestions, len(lines))) seen := make(map[string]struct{}, len(lines)) for index, line := range lines { if strings.TrimSuffix(strings.TrimSpace(line), ":") != "兜底检索问题" { continue } for _, fallbackLine := range lines[index+1:] { text := strings.TrimSpace(fallbackLine) if text == "" { continue } if !strings.HasPrefix(text, "-") { break } appendNormalizedKnowledgeResolveQuery(&queries, seen, strings.TrimSpace(strings.TrimPrefix(text, "-"))) if len(queries) >= maxKnowledgeQueryQuestions { return queries } } if len(queries) > 0 { return queries } } inFallbackQuestions := false for _, line := range lines { text := strings.TrimSpace(line) if text == "" { continue } if strings.TrimSuffix(text, ":") == "兜底检索问题" { inFallbackQuestions = true continue } if strings.Contains(text, ":") && !strings.HasPrefix(text, "-") && !inFallbackQuestions { continue } if inFallbackQuestions && !strings.HasPrefix(text, "-") { continue } text = strings.TrimPrefix(text, "-") text = strings.TrimSpace(text) appendNormalizedKnowledgeResolveQuery(&queries, seen, text) if len(queries) >= maxKnowledgeQueryQuestions { break } } return queries } func appendNormalizedKnowledgeResolveQuery(queries *[]string, seen map[string]struct{}, text string) { if queries == nil || len(*queries) >= maxKnowledgeQueryQuestions { return } text = strings.TrimSpace(text) if text == "" { return } key := strings.ToLower(strings.Join(strings.Fields(text), " ")) if _, exists := seen[key]; exists { return } seen[key] = struct{}{} *queries = append(*queries, text) } // for debug func summarizeKnowledgeSnippetsForLog(snippets []KnowledgeSnippet) []map[string]any { if len(snippets) == 0 { return []map[string]any{} } summary := make([]map[string]any, 0, len(snippets)) for _, snippet := range snippets { entry := map[string]any{ "point_id": snippet.PointID, "group_id": snippet.GroupID, "group_name": strings.TrimSpace(snippet.GroupName), "item_id": snippet.KnowledgeItemID, "item_name": strings.TrimSpace(snippet.ItemName), "score": snippet.Score, "text": knowledgeLogPreview(snippet.Text, 240), } if sourceType := strings.TrimSpace(snippet.SourceType); sourceType != "" { entry["source_type"] = sourceType } if sourceURI := strings.TrimSpace(derefKnowledgeString(snippet.SourceURI)); sourceURI != "" { entry["source_uri"] = sourceURI } if len(snippet.PreciseFacts) > 0 { entry["precise_facts"] = summarizeKnowledgeFactsForLog(snippet.PreciseFacts) } summary = append(summary, entry) } return summary } func summarizeKnowledgeFactsForLog(facts []string) []string { if len(facts) == 0 { return []string{} } limit := len(facts) if limit > 12 { limit = 12 } summary := make([]string, 0, limit) for _, fact := range facts[:limit] { summary = append(summary, knowledgeLogPreview(fact, 240)) } return summary } func knowledgeLogPreview(value string, maxRunes int) string { text := strings.TrimSpace(value) if text == "" { return "" } runes := []rune(text) if maxRunes <= 0 || len(runes) <= maxRunes { return text } return strings.TrimSpace(string(runes[:maxRunes])) + "..." } func knowledgeSnippetsFromSearchPoints(points []retrieval.SearchPoint) []KnowledgeSnippet { if len(points) == 0 { return []KnowledgeSnippet{} } snippets := make([]KnowledgeSnippet, 0, len(points)) for _, point := range points { text := strings.TrimSpace(anyString(point.Payload["text"])) if text == "" { continue } sourceURI := optionalKnowledgeString(point.Payload["source_uri"]) snippets = append(snippets, KnowledgeSnippet{ PointID: strings.TrimSpace(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"])), SourceType: strings.TrimSpace(anyString(point.Payload["source_type"])), SourceURI: sourceURI, Text: text, SearchText: strings.TrimSpace(anyString(point.Payload["search_text"])), Score: point.Score, }) } return snippets } func (snippet KnowledgeSnippet) RerankText() string { if text := strings.TrimSpace(snippet.SearchText); text != "" { return text } return buildKnowledgeSearchText(knowledgeSearchTextInput{ GroupName: snippet.GroupName, ItemName: snippet.ItemName, SourceType: snippet.SourceType, SourceURI: derefKnowledgeString(snippet.SourceURI), Text: snippet.Text, }) } func (snippet KnowledgeSnippet) dedupeKey() string { return normalizeKnowledgeSnippetTextKey(snippet.RerankText()) } func dedupeKnowledgeSearchCandidates(candidates []KnowledgeSnippet) []KnowledgeSnippet { if len(candidates) == 0 { return []KnowledgeSnippet{} } seenPoints := make(map[string]struct{}, len(candidates)) seenTexts := make(map[string]struct{}, len(candidates)) deduped := make([]KnowledgeSnippet, 0, len(candidates)) for _, candidate := range candidates { if pointID := strings.TrimSpace(candidate.PointID); pointID != "" { if _, exists := seenPoints[pointID]; exists { continue } seenPoints[pointID] = struct{}{} } textKey := candidate.dedupeKey() if textKey == "" { continue } if _, exists := seenTexts[textKey]; exists { continue } seenTexts[textKey] = struct{}{} candidate.Text = strings.TrimSpace(candidate.Text) deduped = append(deduped, candidate) } return deduped } func selectKnowledgeCandidateSnippets(candidates []KnowledgeSnippet, limit int) []KnowledgeSnippet { return selectKnowledgeRerankedSnippets(candidates, nil, limit) } func selectKnowledgeRerankedSnippets( candidates []KnowledgeSnippet, reranked []retrieval.RerankResult, limit int, ) []KnowledgeSnippet { if len(candidates) == 0 { return []KnowledgeSnippet{} } if limit <= 0 || limit > len(candidates) { limit = len(candidates) } selected := make([]KnowledgeSnippet, 0, limit) seenPoints := make(map[string]struct{}, limit) seenTexts := make(map[string]struct{}, limit) itemCounts := make(map[int64]int, limit) appendSnippet := func(snippet KnowledgeSnippet, enforceItemLimit bool) { if len(selected) >= limit { return } if pointID := strings.TrimSpace(snippet.PointID); pointID != "" { if _, exists := seenPoints[pointID]; exists { return } } textKey := snippet.dedupeKey() if textKey == "" { return } if _, exists := seenTexts[textKey]; exists { return } if enforceItemLimit && snippet.KnowledgeItemID > 0 && itemCounts[snippet.KnowledgeItemID] >= maxKnowledgeSnippetsPerItem { return } snippet.Text = strings.TrimSpace(snippet.Text) selected = append(selected, snippet) if pointID := strings.TrimSpace(snippet.PointID); pointID != "" { seenPoints[pointID] = struct{}{} } seenTexts[textKey] = struct{}{} if snippet.KnowledgeItemID > 0 { itemCounts[snippet.KnowledgeItemID]++ } } for _, item := range reranked { if item.Index < 0 || item.Index >= len(candidates) { continue } snippet := candidates[item.Index] snippet.Score = item.RelevanceScore appendSnippet(snippet, true) } for _, candidate := range candidates { appendSnippet(candidate, true) } if len(selected) < limit { for _, candidate := range candidates { appendSnippet(candidate, false) } } return selected } func normalizeKnowledgeSnippetTextKey(text string) string { text = strings.TrimSpace(text) if text == "" { return "" } return strings.ToLower(strings.Join(strings.Fields(text), " ")) } func (s *KnowledgeService) RenderPromptSection(ctx *KnowledgeContext) string { if ctx == nil || (len(ctx.Snippets) == 0 && len(ctx.PreciseFacts) == 0 && len(ctx.FactConstraints) == 0) { return "" } lines := prompts.KnowledgePromptIntroLines() preciseFacts := ctx.PreciseFacts if len(preciseFacts) == 0 { preciseFacts = collectKnowledgePromptPreciseFacts(ctx.Snippets) } factConstraints := ctx.FactConstraints if len(factConstraints) == 0 { var conflicts []KnowledgeFactConflict factConstraints, conflicts = buildKnowledgeFactConstraints(preciseFacts) preciseFacts = removeConflictingKnowledgeFacts(preciseFacts, conflicts) } if len(factConstraints) > 0 { lines = append(lines, "以下为系统落库前会自动校验的规范事实;涉及对应主体时只能使用这些值,不得引用旧版本、推算年限或自行补充数字:", ) for _, constraint := range factConstraints { lines = append(lines, "- "+renderKnowledgeFactConstraint(constraint)) } lines = append(lines, "") } if len(preciseFacts) > 0 { lines = append(lines, "以下为必须严格按原文保留的高精度事实;如果正文需要引用,必须逐字一致,不得改写、翻译、推测或补全:", ) for _, fact := range preciseFacts { lines = append(lines, "- "+fact) } lines = append(lines, "") } for index, snippet := range ctx.Snippets { label := knowledgeSnippetPromptLabel(snippet, true, index) referenceID := knowledgeSnippetReferenceID(index) block := fmt.Sprintf("%d. [%s] %s\n%s", index+1, referenceID, label, snippet.Text) if sourceURI := strings.TrimSpace(derefKnowledgeString(snippet.SourceURI)); sourceURI != "" { block = fmt.Sprintf("%s\n来源链接: %s", block, sourceURI) } lines = append(lines, block) } return strings.Join(lines, "\n") } func (s *KnowledgeService) applyKnowledgeFactConstraints(ctx *KnowledgeContext) { if ctx == nil { return } constraints, conflicts := buildKnowledgeFactConstraints(ctx.PreciseFacts) ctx.PreciseFacts = removeConflictingKnowledgeFacts(ctx.PreciseFacts, conflicts) ctx.FactConstraints = constraints ctx.FactConflicts = conflicts if s == nil || s.logger == nil { return } for _, conflict := range conflicts { s.logger.Warn("knowledge fact source conflict", zap.String("fact_kind", string(conflict.Kind)), zap.String("fact_subject", conflict.Subject), zap.String("canonical_value", conflict.CanonicalValue), zap.String("conflicting_value", conflict.ConflictingValue), ) } } type knowledgePromptItemRecord struct { ID int64 GroupID int64 GroupName string Name string SourceType string SourceURI *string ContentText *string MarkdownContent *string } func (s *KnowledgeService) enrichKnowledgePromptSnippets( ctx context.Context, tenantID int64, brandID int64, snippets []KnowledgeSnippet, ) []KnowledgeSnippet { if len(snippets) == 0 { return []KnowledgeSnippet{} } itemIDs := make([]int64, 0, len(snippets)) for _, snippet := range snippets { if snippet.KnowledgeItemID > 0 { itemIDs = append(itemIDs, snippet.KnowledgeItemID) } } itemIDs = normalizeInt64IDs(itemIDs) if len(itemIDs) == 0 { return snippets } items, err := s.loadKnowledgePromptItems(ctx, tenantID, brandID, itemIDs) if err != nil { if s.logger != nil { s.logger.Warn("load knowledge prompt items failed", zap.Error(err)) } return snippets } itemMap := make(map[int64]knowledgePromptItemRecord, len(items)) for _, item := range items { itemMap[item.ID] = item } enriched := make([]KnowledgeSnippet, 0, len(snippets)) for _, snippet := range snippets { item, ok := itemMap[snippet.KnowledgeItemID] if ok { snippet.GroupID = item.GroupID snippet.GroupName = item.GroupName snippet.ItemName = item.Name snippet.SourceType = item.SourceType snippet.SourceURI = item.SourceURI sourceText := derefKnowledgeString(item.MarkdownContent) if strings.TrimSpace(sourceText) == "" { sourceText = derefKnowledgeString(item.ContentText) } snippet.PreciseFacts = extractKnowledgePreciseFacts(sourceText) } enriched = append(enriched, snippet) } return enriched } func (s *KnowledgeService) loadKnowledgePromptItems( ctx context.Context, tenantID int64, brandID int64, itemIDs []int64, ) ([]knowledgePromptItemRecord, error) { if len(itemIDs) == 0 { return []knowledgePromptItemRecord{}, nil } rows, err := s.pool.Query(ctx, ` SELECT ki.id, ki.group_id, kg.name, ki.name, ki.source_type, ki.source_uri, ki.content_text, ki.markdown_content FROM knowledge_items ki JOIN knowledge_groups kg ON kg.id = ki.group_id AND kg.brand_id = ki.brand_id AND kg.deleted_at IS NULL WHERE ki.tenant_id = $1 AND ki.brand_id = $2 AND ki.id = ANY($3::bigint[]) AND ki.status = 'completed' AND ki.deleted_at IS NULL `, tenantID, brandID, itemIDs) if err != nil { return nil, response.ErrInternal(50051, "knowledge_items_query_failed", "failed to load knowledge prompt items") } defer rows.Close() items := make([]knowledgePromptItemRecord, 0, len(itemIDs)) for rows.Next() { var item knowledgePromptItemRecord if err := rows.Scan( &item.ID, &item.GroupID, &item.GroupName, &item.Name, &item.SourceType, &item.SourceURI, &item.ContentText, &item.MarkdownContent, ); err != nil { return nil, response.ErrInternal(50051, "knowledge_items_scan_failed", err.Error()) } items = append(items, item) } return items, nil } func (s *KnowledgeService) collectKnowledgeContextPreciseFacts( ctx context.Context, tenantID int64, brandID int64, groupIDs []int64, snippets []KnowledgeSnippet, ) []string { groupIDs = normalizeInt64IDs(groupIDs) if len(groupIDs) == 0 { return collectKnowledgePromptPreciseFacts(snippets) } matchedItemIDs := make([]int64, 0, len(snippets)) for _, snippet := range snippets { if snippet.KnowledgeItemID > 0 { matchedItemIDs = append(matchedItemIDs, snippet.KnowledgeItemID) } } matchedItemIDs = normalizeInt64IDs(matchedItemIDs) items, err := s.loadKnowledgePromptItemsByGroupIDs(ctx, tenantID, brandID, groupIDs, matchedItemIDs, 24) if err != nil { if s.logger != nil { s.logger.Warn("collect knowledge context precise facts failed", zap.Error(err)) } return collectKnowledgePromptPreciseFacts(snippets) } facts := make([]string, 0, 24) seen := make(map[string]struct{}, 24) for _, item := range items { sourceText := derefKnowledgeString(item.MarkdownContent) if strings.TrimSpace(sourceText) == "" { sourceText = derefKnowledgeString(item.ContentText) } label := strings.TrimSpace(item.Name) if strings.TrimSpace(item.GroupName) != "" && label != "" { label = fmt.Sprintf("%s / %s", strings.TrimSpace(item.GroupName), label) } else if strings.TrimSpace(item.GroupName) != "" { label = strings.TrimSpace(item.GroupName) } for _, fact := range extractKnowledgePreciseFacts(sourceText) { value := strings.TrimSpace(fact) if value == "" { continue } entry := value if label != "" { entry = fmt.Sprintf("%s:%s", label, value) } if _, exists := seen[entry]; exists { continue } seen[entry] = struct{}{} facts = append(facts, entry) if len(facts) >= 24 { return facts } } } if len(facts) == 0 { return collectKnowledgePromptPreciseFacts(snippets) } return facts } func (s *KnowledgeService) loadKnowledgePromptItemsByGroupIDs( ctx context.Context, tenantID int64, brandID int64, groupIDs []int64, prioritizedItemIDs []int64, limit int, ) ([]knowledgePromptItemRecord, error) { groupIDs = normalizeInt64IDs(groupIDs) prioritizedItemIDs = normalizeInt64IDs(prioritizedItemIDs) if len(groupIDs) == 0 { return []knowledgePromptItemRecord{}, nil } if limit <= 0 { limit = 24 } rows, err := s.pool.Query(ctx, ` WITH RECURSIVE selected_groups AS ( SELECT id FROM knowledge_groups WHERE tenant_id = $1 AND brand_id = $2 AND id = ANY($3::bigint[]) 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.brand_id = $2 AND child.deleted_at IS NULL ) SELECT ki.id, ki.group_id, kg.name, ki.name, ki.source_type, ki.source_uri, ki.content_text, ki.markdown_content FROM knowledge_items ki JOIN knowledge_groups kg ON kg.id = ki.group_id AND kg.brand_id = ki.brand_id AND kg.deleted_at IS NULL WHERE ki.tenant_id = $1 AND ki.brand_id = $2 AND ki.status = 'completed' AND ki.deleted_at IS NULL AND ki.group_id IN (SELECT id FROM selected_groups) ORDER BY ki.updated_at DESC, CASE WHEN ki.id = ANY($4::bigint[]) THEN 0 ELSE 1 END, ki.id DESC LIMIT $5 `, tenantID, brandID, groupIDs, prioritizedItemIDs, limit) if err != nil { return nil, response.ErrInternal(50051, "knowledge_items_query_failed", "failed to load knowledge prompt items by groups") } defer rows.Close() items := make([]knowledgePromptItemRecord, 0, limit) for rows.Next() { var item knowledgePromptItemRecord if err := rows.Scan( &item.ID, &item.GroupID, &item.GroupName, &item.Name, &item.SourceType, &item.SourceURI, &item.ContentText, &item.MarkdownContent, ); err != nil { return nil, response.ErrInternal(50051, "knowledge_items_scan_failed", err.Error()) } items = append(items, item) } return items, nil } func collectKnowledgePromptPreciseFacts(snippets []KnowledgeSnippet) []string { if len(snippets) == 0 { return nil } facts := make([]string, 0, len(snippets)*2) seen := make(map[string]struct{}, len(snippets)*2) for index, snippet := range snippets { label := knowledgeSnippetPromptLabel(snippet, false, index) for _, fact := range snippet.PreciseFacts { value := strings.TrimSpace(fact) if value == "" { continue } entry := fmt.Sprintf("%s:%s", label, value) if _, exists := seen[entry]; exists { continue } seen[entry] = struct{}{} facts = append(facts, entry) } } return facts } func knowledgeSnippetPromptLabel(snippet KnowledgeSnippet, fallbackToDefault bool, index int) string { label := strings.TrimSpace(snippet.ItemName) if label == "" && fallbackToDefault { label = prompts.KnowledgeSnippetLabel(index) } groupName := strings.TrimSpace(snippet.GroupName) if groupName != "" && label != "" { return fmt.Sprintf("%s / %s", groupName, label) } if groupName != "" { return groupName } return label } func knowledgeSnippetReferenceID(index int) string { return fmt.Sprintf("K%d", index+1) } func extractKnowledgePreciseFacts(text string) []string { normalized := normalizeKnowledgeMarkdown(text) if normalized == "" { normalized = normalizeKnowledgeText(text) } if normalized == "" { return nil } lines := strings.Split(normalized, "\n") facts := make([]string, 0, 12) seen := make(map[string]struct{}, 12) appendFact := func(value string) { value = normalizeKnowledgeInlineText(trimKnowledgeFactLine(value)) if value == "" { return } if _, exists := seen[value]; exists { return } seen[value] = struct{}{} facts = append(facts, value) } for index := 0; index < len(lines); index++ { line := trimKnowledgeFactLine(lines[index]) if line == "" || strings.HasPrefix(line, "#") { continue } if knowledgePreciseFactLabelOnly(line) { sectionContent := collectKnowledgePreciseFactSection(lines, index, line) if sectionContent != "" { appendFact(strings.TrimRight(strings.TrimSpace(line), "::") + ":" + sectionContent) } continue } if knowledgeLineContainsPreciseFact(line) { appendFact(line) } } if len(facts) > 12 { return facts[:12] } return facts } func collectKnowledgePreciseFactSection(lines []string, startIndex int, label string) string { maxLines := knowledgePreciseFactSectionLineLimit(label) if maxLines <= 0 { maxLines = 1 } items := make([]string, 0, maxLines) for index := startIndex + 1; index < len(lines) && len(items) < maxLines; index++ { line := trimKnowledgeFactLine(lines[index]) if line == "" { if len(items) > 0 { break } continue } if strings.HasPrefix(line, "#") { break } if knowledgePreciseFactLabelOnly(line) { break } if len(items) > 0 && knowledgeLineStartsAnotherPreciseFact(line) && !knowledgeLineContainsBusinessFact(line) { break } items = append(items, line) } return strings.Join(items, ";") } func knowledgePreciseFactSectionLineLimit(line string) int { if knowledgeLineContainsBusinessFact(line) { return 4 } return 1 } func trimKnowledgeFactLine(value string) string { line := strings.TrimSpace(value) line = strings.TrimLeft(line, "-*• \t") line = strings.TrimLeft(line, "0123456789.、)) \t") return strings.TrimSpace(line) } func knowledgePreciseFactLabelOnly(line string) bool { normalized := strings.ToLower(strings.Trim(strings.TrimSpace(line), "::")) switch normalized { case "地址", "门店地址", "公司地址", "办公地址", "总部地址", "联系地址", "location", "address", "电话", "联系电话", "客服热线", "phone", "tel", "telephone", "官网", "网址", "网站", "website", "url", "邮箱", "电子邮箱", "email", "e-mail", "营业时间", "开放时间", "服务时间", "办公时间", "hours", "opening hours", "主营业务", "主要业务", "核心业务", "业务范围", "经营范围", "服务范围", "主营产品", "主要产品", "核心产品", "产品范围", "main business", "core business", "business scope", "service scope", "main products", "core products", "product scope", "成立时间", "创立时间", "创办时间", "创建时间", "注册时间", "注册成立时间", "服务年限", "行业经验", "从业经验", "经营年限", "company founded", "founded", "established": return true default: return false } } func knowledgeLineContainsPreciseFact(line string) bool { lower := strings.ToLower(strings.TrimSpace(line)) if lower == "" { return false } keywords := []string{ "地址", "门店地址", "公司地址", "办公地址", "总部地址", "联系地址", "邮编", "位置", "坐标", "电话", "联系电话", "客服热线", "热线", "传真", "官网", "网址", "网站", "链接", "邮箱", "电子邮箱", "邮件", "营业时间", "开放时间", "服务时间", "办公时间", "主营业务", "主要业务", "核心业务", "业务范围", "经营范围", "服务范围", "主营产品", "主要产品", "核心产品", "产品范围", "address", "location", "phone", "tel", "telephone", "website", "email", "e-mail", "opening hours", "business hours", "postcode", "zip code", "main business", "core business", "business scope", "service scope", "main products", "core products", "product scope", "成立于", "成立时间", "创立于", "创立时间", "创办于", "创办时间", "创建于", "创建时间", "注册成立", "注册时间", "服务经验", "行业经验", "从业经验", "经营经验", "经营年限", "深耕", "founded", "established", "years of experience", } for _, keyword := range keywords { if strings.Contains(lower, keyword) { return true } } if strings.Contains(lower, "http://") || strings.Contains(lower, "https://") { return true } if strings.Contains(lower, "@") { return true } digits := 0 for _, r := range lower { if r >= '0' && r <= '9' { digits++ } } return digits >= 7 && (strings.Contains(lower, "-") || strings.Contains(lower, " ") || strings.Contains(lower, "(")) } func knowledgeLineStartsAnotherPreciseFact(line string) bool { trimmed := strings.TrimSpace(line) if trimmed == "" { return false } if knowledgePreciseFactLabelOnly(trimmed) { return true } lower := strings.ToLower(trimmed) labels := []string{ "地址:", "门店地址:", "公司地址:", "办公地址:", "总部地址:", "联系地址:", "电话:", "联系电话:", "客服热线:", "传真:", "官网:", "网址:", "网站:", "邮箱:", "电子邮箱:", "营业时间:", "开放时间:", "服务时间:", "办公时间:", "主营业务:", "主要业务:", "核心业务:", "业务范围:", "经营范围:", "服务范围:", "主营产品:", "主要产品:", "核心产品:", "产品范围:", "address:", "location:", "phone:", "tel:", "telephone:", "website:", "email:", "e-mail:", "opening hours:", "business hours:", "main business:", "core business:", "business scope:", "service scope:", "main products:", "core products:", "product scope:", "成立时间:", "创立时间:", "创办时间:", "创建时间:", "注册时间:", "注册成立时间:", "服务年限:", "行业经验:", "从业经验:", "经营年限:", "company founded:", "founded:", "established:", } for _, label := range labels { if strings.HasPrefix(lower, label) { return true } } return false } func knowledgeLineContainsBusinessFact(line string) bool { lower := strings.ToLower(strings.TrimSpace(line)) if lower == "" { return false } keywords := []string{ "主营业务", "主要业务", "核心业务", "业务范围", "经营范围", "服务范围", "主营产品", "主要产品", "核心产品", "产品范围", "main business", "core business", "business scope", "service scope", "main products", "core products", "product scope", } for _, keyword := range keywords { if strings.Contains(lower, keyword) { return true } } return false } 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" { runtimeCfg := s.runtimeConfig() if strings.TrimSpace(runtimeCfg.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, brandID int64, input knowledgeIngestInput, ) (*KnowledgeItemResponse, error) { resolvedGroupID, err := s.resolveKnowledgeStorageGroupID(ctx, tenantID, brandID, 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, brandID, 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, brandID, []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 } } chunkSource := strings.TrimSpace(parsedContent.Markdown) if chunkSource == "" { chunkSource = strings.TrimSpace(parsedContent.Text) } runtimeCfg := s.runtimeConfig() chunks := splitKnowledgeTextIntoChunks(chunkSource, runtimeCfg.ChunkSize, runtimeCfg.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 } } searchTexts := make([]string, 0, len(chunks)) for _, chunk := range chunks { searchTexts = append(searchTexts, buildKnowledgeSearchText(knowledgeSearchTextInput{ GroupName: item.GroupName, ItemName: item.Name, SourceType: item.SourceType, SourceURI: derefKnowledgeString(item.SourceURI), Text: chunk, })) } embeddings, err := s.embedChunks(ctx, searchTexts) 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, "brand_id": item.BrandID, "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, "source_uri": derefKnowledgeString(item.SourceURI), "text": chunk, "search_text": searchTexts[index], }, }) 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.brand_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 AND kg.brand_id = ki.brand_id WHERE ki.id = $1 AND ki.tenant_id = $2 AND ki.deleted_at IS NULL `, itemID, tenantID).Scan( &record.ID, &record.TenantID, &record.BrandID, &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, brandID 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, brand_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, $8, NULL, 'pending', $9, 1) RETURNING id `, tenantID, brandID, 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 optionalKnowledgeString(value any) *string { text := strings.TrimSpace(anyString(value)) if text == "" { return nil } return &text } type knowledgeSearchTextInput struct { GroupName string ItemName string SourceType string SourceURI string Text string } func buildKnowledgeSearchText(input knowledgeSearchTextInput) string { text := strings.TrimSpace(input.Text) if text == "" { return "" } metadata := make([]string, 0, 4) if groupName := strings.TrimSpace(input.GroupName); groupName != "" { metadata = append(metadata, "目录: "+groupName) } if itemName := strings.TrimSpace(input.ItemName); itemName != "" { metadata = append(metadata, "标题: "+itemName) } if sourceType := strings.TrimSpace(input.SourceType); sourceType != "" { metadata = append(metadata, "来源类型: "+sourceType) } if sourceURI := strings.TrimSpace(input.SourceURI); sourceURI != "" { metadata = append(metadata, "来源链接: "+sourceURI) } if len(metadata) == 0 { return text } metadata = append(metadata, "内容:", text) return strings.Join(metadata, "\n") } func (s *KnowledgeService) ensureGroupAccessible(ctx context.Context, tenantID, brandID, 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 brand_id = $3 AND deleted_at IS NULL ) `, groupID, tenantID, brandID).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, brandID, groupID int64) error { if err := s.ensureGroupAccessible(ctx, tenantID, brandID, 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 brand_id = $3 AND deleted_at IS NULL AND parent_id IS NULL ) `, groupID, tenantID, brandID).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, brandID, groupID int64) error { if err := s.ensureGroupAccessible(ctx, tenantID, brandID, 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.brand_id = child.brand_id AND parent.deleted_at IS NULL WHERE child.id = $1 AND child.tenant_id = $2 AND child.brand_id = $3 AND child.deleted_at IS NULL AND parent.parent_id IS NULL ) `, groupID, tenantID, brandID).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, brandID, 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, brandID, 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, brandID, 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 brand_id = $3 AND deleted_at IS NULL FOR UPDATE `, groupID, tenantID, brandID).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 brand_id = $3 AND deleted_at IS NULL `, *parentID, tenantID, brandID).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 brand_id = $2 AND parent_id = $3 AND name = $4 AND deleted_at IS NULL ORDER BY id LIMIT 1 `, tenantID, brandID, 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, brand_id, name, parent_id, sort_order) VALUES ($1, $2, $3, $4, 0) RETURNING id `, tenantID, brandID, defaultKnowledgeChildGroupName, groupID).Scan(&childGroupID); err == nil { return childGroupID, nil } if err := tx.QueryRow(ctx, ` SELECT id FROM knowledge_groups WHERE tenant_id = $1 AND brand_id = $2 AND parent_id = $3 AND name = $4 AND deleted_at IS NULL ORDER BY id LIMIT 1 `, tenantID, brandID, 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, brandID, 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 brand_id = $3 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.brand_id = $3 AND child.deleted_at IS NULL ) SELECT id FROM selected_groups `, groupID, tenantID, brandID) 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, brandID 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 brand_id = $2 AND deleted_at IS NULL AND group_id = ANY($3::bigint[]) `, tenantID, brandID, 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) { runtimeCfg := s.runtimeConfig() results := make([][]float64, 0, len(chunks)) for start := 0; start < len(chunks); start += runtimeCfg.EmbeddingBatchSize { end := start + runtimeCfg.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, brandID 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 brand_id = $2 AND id = ANY($3::bigint[]) AND status = 'completed' AND deleted_at IS NULL `, tenantID, brandID, 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) 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) error { item, err := s.loadKnowledgeItemForCleanup(ctx, tenantID, itemID) if err != nil || item == nil { return err } 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 fmt.Errorf("knowledge cleanup failed: %s", strings.Join(errs, "; ")) } 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 err } 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) return nil } 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.brand_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 AND kg.brand_id = ki.brand_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.BrandID, &record.GroupID, &record.GroupName, &record.Name, &record.SourceType, &record.SourceURI, &record.StorageKey, &contentText, &markdownContent, &record.ItemVersion, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return nil, err } 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, brandID 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 AND kg.brand_id = ki.brand_id WHERE ki.tenant_id = $1 AND ki.brand_id = $2 AND ki.id = ANY($3::bigint[]) AND ki.deleted_at IS NULL ORDER BY ki.created_at DESC, ki.id DESC `, tenantID, brandID, 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) resolveKnowledgeSearchScope(ctx context.Context, tenantID, brandID int64, groupIDs []int64) ([]int64, []string, error) { groupIDs = normalizeInt64IDs(groupIDs) if len(groupIDs) == 0 { return []int64{}, []string{}, nil } rows, err := s.pool.Query(ctx, ` WITH RECURSIVE selected_groups AS ( SELECT id, name, parent_id, sort_order, created_at FROM knowledge_groups WHERE tenant_id = $1 AND brand_id = $2 AND id = ANY($3::bigint[]) AND deleted_at IS NULL UNION SELECT child.id, child.name, child.parent_id, child.sort_order, child.created_at FROM knowledge_groups child JOIN selected_groups parent ON child.parent_id = parent.id WHERE child.tenant_id = $1 AND child.brand_id = $2 AND child.deleted_at IS NULL ) SELECT id, name FROM selected_groups ORDER BY sort_order, created_at, id `, tenantID, brandID, groupIDs) if err != nil { return nil, nil, response.ErrInternal(50050, "knowledge_group_query_failed", "failed to query knowledge groups") } defer rows.Close() resolvedIDs := make([]int64, 0, len(groupIDs)) names := make([]string, 0, len(groupIDs)) seenIDs := make(map[int64]struct{}, len(groupIDs)) seenNames := make(map[string]struct{}, len(groupIDs)) for rows.Next() { var ( groupID int64 name string ) if err := rows.Scan(&groupID, &name); err != nil { return nil, nil, response.ErrInternal(50050, "knowledge_group_query_failed", err.Error()) } if groupID > 0 { if _, exists := seenIDs[groupID]; !exists { seenIDs[groupID] = struct{}{} resolvedIDs = append(resolvedIDs, groupID) } } name = strings.TrimSpace(name) if name != "" { if _, exists := seenNames[name]; !exists { seenNames[name] = struct{}{} names = append(names, name) } } } return resolvedIDs, 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 nil: return "" case string: return typed case []byte: return string(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 }