feat: Enhance Kol Generation Service with web search and knowledge group support

- Added `EnableWebSearch` and `KnowledgeGroupIDs` fields to `KolGenerationSubmitRequest`.
- Updated `Submit` method in `KolGenerationService` to handle new request fields.
- Integrated web search and knowledge group validation based on card configuration.
- Introduced caching mechanisms in `KolPackageService`, `KolPromptService`, and `KolSubscriptionAdminService` to improve performance.
- Implemented knowledge context resolution in `KolGenerationWorker` to enrich prompts with relevant knowledge snippets.
- Added utility functions for handling card configuration in both backend and frontend.
- Created tests for knowledge extraction and rendering to ensure accuracy and reliability.
This commit is contained in:
2026-04-18 13:47:32 +08:00
parent 3ef0807456
commit b2605abd6a
27 changed files with 2051 additions and 171 deletions
+540 -22
View File
@@ -137,9 +137,10 @@ type KnowledgeURLItemRequest struct {
}
type KnowledgeContext struct {
GroupIDs []int64
GroupNames []string
Snippets []KnowledgeSnippet
GroupIDs []int64
GroupNames []string
Snippets []KnowledgeSnippet
PreciseFacts []string
}
type KnowledgeSnippet struct {
@@ -148,8 +149,11 @@ type KnowledgeSnippet struct {
GroupName string
KnowledgeItemID int64
ItemName string
SourceType string
SourceURI *string
Text string
Score float64
PreciseFacts []string
}
func NewKnowledgeService(
@@ -761,6 +765,13 @@ func (s *KnowledgeService) ResolveContext(
return nil, nil
}
baseContext := &KnowledgeContext{
GroupIDs: groupIDs,
GroupNames: groupNames,
Snippets: []KnowledgeSnippet{},
PreciseFacts: s.collectKnowledgeContextPreciseFacts(ctx, tenantID, groupIDs, nil),
}
vectors, err := s.provider.Embed(ctx, []string{query})
if err != nil {
return nil, response.ErrServiceUnavailable(50353, "embedding_failed", err.Error())
@@ -779,7 +790,7 @@ func (s *KnowledgeService) ResolveContext(
return nil, response.ErrServiceUnavailable(50354, "qdrant_search_failed", err.Error())
}
if len(searchPoints) == 0 {
return &KnowledgeContext{GroupIDs: groupIDs, GroupNames: groupNames, Snippets: []KnowledgeSnippet{}}, nil
return baseContext, nil
}
candidates := make([]KnowledgeSnippet, 0, len(searchPoints))
@@ -801,7 +812,7 @@ func (s *KnowledgeService) ResolveContext(
documents = append(documents, text)
}
if len(candidates) == 0 {
return &KnowledgeContext{GroupIDs: groupIDs, GroupNames: groupNames, Snippets: []KnowledgeSnippet{}}, nil
return baseContext, nil
}
candidates, err = s.filterActiveKnowledgeSnippets(ctx, tenantID, candidates)
@@ -809,7 +820,7 @@ func (s *KnowledgeService) ResolveContext(
return nil, err
}
if len(candidates) == 0 {
return &KnowledgeContext{GroupIDs: groupIDs, GroupNames: groupNames, Snippets: []KnowledgeSnippet{}}, nil
return baseContext, nil
}
documents = documents[:0]
@@ -820,7 +831,10 @@ func (s *KnowledgeService) ResolveContext(
reranked, err := s.provider.Rerank(ctx, query, documents, minInt(s.rerankTopN, len(documents)))
if err != nil {
selected := candidates[:minInt(len(candidates), s.rerankTopN)]
return &KnowledgeContext{GroupIDs: groupIDs, GroupNames: groupNames, Snippets: selected}, nil
selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, selected)
baseContext.Snippets = selected
baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, groupIDs, selected)
return baseContext, nil
}
selected := make([]KnowledgeSnippet, 0, len(reranked))
@@ -836,33 +850,532 @@ func (s *KnowledgeService) ResolveContext(
selected = append(selected, snippet)
}
return &KnowledgeContext{
GroupIDs: groupIDs,
GroupNames: groupNames,
Snippets: selected,
}, nil
selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, selected)
baseContext.Snippets = selected
baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, groupIDs, selected)
return baseContext, nil
}
func (s *KnowledgeService) RenderPromptSection(ctx *KnowledgeContext) string {
if ctx == nil || len(ctx.Snippets) == 0 {
if ctx == nil || (len(ctx.Snippets) == 0 && len(ctx.PreciseFacts) == 0) {
return ""
}
lines := prompts.KnowledgePromptIntroLines()
preciseFacts := ctx.PreciseFacts
if len(preciseFacts) == 0 {
preciseFacts = collectKnowledgePromptPreciseFacts(ctx.Snippets)
}
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 := strings.TrimSpace(snippet.ItemName)
if label == "" {
label = prompts.KnowledgeSnippetLabel(index)
label := knowledgeSnippetPromptLabel(snippet, true, index)
block := fmt.Sprintf("%d. %s\n%s", index+1, label, snippet.Text)
if sourceURI := strings.TrimSpace(derefKnowledgeString(snippet.SourceURI)); sourceURI != "" {
block = fmt.Sprintf("%s\n来源链接: %s", block, sourceURI)
}
groupName := strings.TrimSpace(snippet.GroupName)
if groupName != "" {
label = fmt.Sprintf("%s / %s", groupName, label)
}
lines = append(lines, fmt.Sprintf("%d. %s\n%s", index+1, label, snippet.Text))
lines = append(lines, block)
}
return strings.Join(lines, "\n")
}
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,
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, 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,
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.deleted_at IS NULL
WHERE ki.tenant_id = $1
AND ki.id = ANY($2::bigint[])
AND ki.deleted_at IS NULL
`, tenantID, 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,
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, 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,
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 id = ANY($2::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.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.deleted_at IS NULL
WHERE ki.tenant_id = $1
AND ki.deleted_at IS NULL
AND ki.group_id IN (SELECT id FROM selected_groups)
ORDER BY
CASE
WHEN ki.id = ANY($3::bigint[]) THEN 0
ELSE 1
END,
ki.updated_at DESC,
ki.id DESC
LIMIT $4
`, tenantID, 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 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":
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",
}
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:",
}
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
@@ -1036,7 +1549,12 @@ func (s *KnowledgeService) executeParseJob(ctx context.Context, job knowledgePar
}
}
chunks := splitKnowledgeTextIntoChunks(parsedContent.Text, s.chunkSize, s.chunkOverlap)
chunkSource := strings.TrimSpace(parsedContent.Markdown)
if chunkSource == "" {
chunkSource = strings.TrimSpace(parsedContent.Text)
}
chunks := splitKnowledgeTextIntoChunks(chunkSource, s.chunkSize, s.chunkOverlap)
if len(chunks) == 0 {
err = errors.New("knowledge content is empty after parsing")
_ = s.markKnowledgeIngestionFailed(context.Background(), job.TenantID, job.ItemID, job.ParseTaskID, err)