Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
203 lines
6.7 KiB
Go
203 lines
6.7 KiB
Go
package workbench
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
)
|
|
|
|
type SegmentCategory struct {
|
|
ID string `json:"id"`
|
|
KnowledgeBaseID string `json:"knowledge_base_id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// Resolve the permitted task scope before enumerating its categories. Explicit
|
|
// files remain explicit: selecting a category must never open the entire base.
|
|
func (s *Service) segmentCategories(ctx context.Context, d TaskData) ([]SegmentCategory, error) {
|
|
result := []SegmentCategory{}
|
|
if s.knowledgeBases == nil || s.knowledge == nil {
|
|
return result, nil
|
|
}
|
|
if err := s.validateKnowledgeScope(ctx, d.KBIDs, d.KnowledgeIDs, d.KnowledgeTags); err != nil {
|
|
return nil, err
|
|
}
|
|
tenant, _, _, err := identity(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
baseIDs := slices.Clone(d.KBIDs)
|
|
for id := range d.KnowledgeTags {
|
|
baseIDs = append(baseIDs, id)
|
|
}
|
|
files, err := s.knowledge.GetKnowledgeBatchWithSharedAccess(ctx, tenant, d.KnowledgeIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, file := range files {
|
|
if file != nil {
|
|
baseIDs = append(baseIDs, file.KnowledgeBaseID)
|
|
}
|
|
}
|
|
sort.Strings(baseIDs)
|
|
for _, id := range slices.Compact(baseIDs) {
|
|
base, err := s.knowledgeBases.GetKnowledgeBaseByID(ctx, id)
|
|
if err != nil || base == nil {
|
|
return nil, ErrForbidden
|
|
}
|
|
if err := s.validateKnowledgeScope(ctx, []string{id}, nil, nil); err != nil {
|
|
return nil, err
|
|
}
|
|
q := s.db.WithContext(ctx).Model(&types.KnowledgeTag{}).Where("tenant_id=? AND knowledge_base_id=?", base.TenantID, id)
|
|
if tags := d.KnowledgeTags[id]; len(tags) > 0 && len(d.KnowledgeFolders[id]) == 0 {
|
|
q = q.Where("id IN ?", tags)
|
|
}
|
|
allowedFiles := []string{}
|
|
for _, file := range files {
|
|
if file != nil && file.KnowledgeBaseID == id {
|
|
allowedFiles = append(allowedFiles, file.ID)
|
|
}
|
|
}
|
|
wholeBase := slices.Contains(d.KBIDs, id) && len(d.KnowledgeTags[id]) == 0 && len(d.KnowledgeFolders[id]) == 0
|
|
if !wholeBase && len(allowedFiles) > 0 {
|
|
q = q.Where("id IN (SELECT tag_id FROM knowledge_tag_relations WHERE knowledge_id IN ?)", allowedFiles)
|
|
}
|
|
var rows []types.KnowledgeTag
|
|
if err := q.Order("sort_order, name, id").Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, row := range rows {
|
|
result = append(result, SegmentCategory{row.ID, row.KnowledgeBaseID, row.Name})
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func normalizeCategoryRecommendations(segments []Segment, options []SegmentCategory) error {
|
|
allowed := map[string]bool{}
|
|
for _, option := range options {
|
|
allowed[option.ID] = true
|
|
}
|
|
for i := range segments {
|
|
seg := &segments[i]
|
|
if seg.RecommendedCategoryID != "" && !allowed[seg.RecommendedCategoryID] {
|
|
return fmt.Errorf("%w: AI 推荐了当前 Agent 资料范围以外的分类,请重新拆解", ErrInvalid)
|
|
}
|
|
seg.CategoryID = seg.RecommendedCategoryID
|
|
if seg.CategoryID == "" {
|
|
seg.CategoryReason = "当前范围没有合适分类,沿用本段 Agent 的资料范围。"
|
|
} else if strings.TrimSpace(seg.CategoryReason) == "" {
|
|
return fmt.Errorf("%w: AI 未说明分类推荐理由,请重新拆解", ErrInvalid)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) agentCategoryOptions(ctx context.Context, d TaskData, flow FlowData) (map[string][]SegmentCategory, error) {
|
|
result := map[string][]SegmentCategory{}
|
|
for _, node := range flow.Nodes {
|
|
if _, exists := result[node.AgentID]; exists {
|
|
continue
|
|
}
|
|
scope := d
|
|
selected := flow
|
|
selected.Nodes = []FlowNode{{ID: node.ID, AgentID: node.AgentID}}
|
|
if err := s.inheritFlow(ctx, &scope, selected); err != nil {
|
|
return nil, err
|
|
}
|
|
options, err := s.segmentCategories(ctx, scope)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result[node.AgentID] = options
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func validateAgentCategoryRecommendations(segments []Segment, options map[string][]SegmentCategory) error {
|
|
for _, segment := range segments {
|
|
if segment.RecommendedCategoryID != "" && !slices.ContainsFunc(options[segment.AgentID], func(option SegmentCategory) bool {
|
|
return option.ID == segment.RecommendedCategoryID
|
|
}) {
|
|
return fmt.Errorf("%w: 本段推荐分类不属于负责 Agent 的资料范围,请重新拆解", ErrInvalid)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// A recommendation is optional metadata. Discard invalid AI suggestions without
|
|
// widening the assigned Agent's scope or losing an otherwise valid decomposition.
|
|
func reconcileCategoryRecommendations(segments []Segment, options []SegmentCategory, byAgent map[string][]SegmentCategory) error {
|
|
for i := range segments {
|
|
segment := &segments[i]
|
|
if segment.RecommendedCategoryID == "" {
|
|
continue
|
|
}
|
|
allowed := func(option SegmentCategory) bool { return option.ID == segment.RecommendedCategoryID }
|
|
if !slices.ContainsFunc(options, allowed) || !slices.ContainsFunc(byAgent[segment.AgentID], allowed) || strings.TrimSpace(segment.CategoryReason) == "" {
|
|
segment.RecommendedCategoryID = ""
|
|
segment.CategoryReason = ""
|
|
}
|
|
}
|
|
if err := normalizeCategoryRecommendations(segments, options); err != nil {
|
|
return err
|
|
}
|
|
return validateAgentCategoryRecommendations(segments, byAgent)
|
|
}
|
|
|
|
func (s *Service) applySegmentCategory(ctx context.Context, scope *TaskData, categoryID string) error {
|
|
if categoryID == "" {
|
|
return nil
|
|
}
|
|
options, err := s.segmentCategories(ctx, *scope)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var selected *SegmentCategory
|
|
for i := range options {
|
|
if options[i].ID == categoryID {
|
|
selected = &options[i]
|
|
break
|
|
}
|
|
}
|
|
if selected == nil {
|
|
return fmt.Errorf("%w: 本段分类不在当前 Agent 的知识范围内,请重新选择", ErrInvalid)
|
|
}
|
|
baseID := selected.KnowledgeBaseID
|
|
tenant, _, _, err := identity(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
files, err := s.knowledge.GetKnowledgeBatchWithSharedAccess(ctx, tenant, scope.KnowledgeIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fileIDs := []string{}
|
|
for _, file := range files {
|
|
if file != nil && file.KnowledgeBaseID == baseID {
|
|
fileIDs = append(fileIDs, file.ID)
|
|
}
|
|
}
|
|
wholeBase := slices.Contains(scope.KBIDs, baseID) && len(scope.KnowledgeTags[baseID]) == 0 && len(scope.KnowledgeFolders[baseID]) == 0
|
|
if wholeBase || len(fileIDs) == 0 {
|
|
scope.KBIDs, scope.KnowledgeIDs = nil, nil
|
|
scope.KnowledgeFolders = nil
|
|
scope.KnowledgeTags = map[string][]string{baseID: {categoryID}}
|
|
return nil
|
|
}
|
|
var ids []string
|
|
if err := s.db.WithContext(ctx).Table("knowledge_tag_relations").Where("tag_id=? AND knowledge_id IN ?", categoryID, fileIDs).Pluck("knowledge_id", &ids).Error; err != nil {
|
|
return err
|
|
}
|
|
if len(ids) == 0 {
|
|
return fmt.Errorf("%w: 所选分类在当前资料范围内没有文件", ErrInvalid)
|
|
}
|
|
scope.KBIDs, scope.KnowledgeFolders, scope.KnowledgeTags = nil, nil, nil
|
|
scope.KnowledgeIDs = ids
|
|
return nil
|
|
}
|