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
+65 -12
View File
@@ -71,6 +71,7 @@ type ArticleListParams struct {
SourceType *string
GenerationMode *string
TemplateID *int64
KolPromptID *int64
PromptRuleID *int64
Keyword *string
CreatedFrom *time.Time
@@ -147,7 +148,7 @@ func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailRe
}
func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, params ArticleListParams) (*ArticleListResponse, error) {
sourceType := params.SourceType
sourceTypes := normalizeArticleSourceTypes(params.SourceType)
if params.Page < 1 {
params.Page = 1
@@ -182,11 +183,7 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
countArgs = append(countArgs, *params.PublishStatus)
argIdx++
}
if sourceType != nil {
countQuery += ` AND a.source_type = $` + strconv.Itoa(argIdx)
countArgs = append(countArgs, *sourceType)
argIdx++
}
countQuery, countArgs, argIdx = appendArticleSourceTypeFilter(countQuery, countArgs, argIdx, sourceTypes)
if params.GenerationMode != nil {
countQuery += ` AND a.source_type = 'custom_generation' AND COALESCE(NULLIF(gt.input_params_json ->> 'generation_mode', ''), 'instant') = $` + strconv.Itoa(argIdx)
countArgs = append(countArgs, *params.GenerationMode)
@@ -197,6 +194,11 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
countArgs = append(countArgs, *params.TemplateID)
argIdx++
}
if params.KolPromptID != nil {
countQuery += ` AND a.kol_prompt_id = $` + strconv.Itoa(argIdx)
countArgs = append(countArgs, *params.KolPromptID)
argIdx++
}
if params.PromptRuleID != nil {
countQuery += ` AND a.prompt_rule_id = $` + strconv.Itoa(argIdx)
countArgs = append(countArgs, *params.PromptRuleID)
@@ -223,7 +225,8 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
}
listQuery := `SELECT a.id, a.source_type, a.template_id, a.kol_prompt_id, a.prompt_rule_id, a.generate_status, a.publish_status, a.created_at,
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')), gt.error_message, COALESCE(av.word_count, 0), av.source_label, t.template_name, pr.name, a.wizard_state_json, gt.input_params_json
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')), gt.error_message, COALESCE(av.word_count, 0), av.source_label,
COALESCE(t.template_name, NULLIF(gt.input_params_json ->> 'prompt_name', ''), NULLIF(gt.input_params_json ->> 'package_name', '')), pr.name, a.wizard_state_json, gt.input_params_json
FROM articles a
LEFT JOIN article_versions av ON av.id = a.current_version_id
LEFT JOIN article_templates t ON t.id = a.template_id
@@ -249,11 +252,7 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
listArgs = append(listArgs, *params.PublishStatus)
listIdx++
}
if sourceType != nil {
listQuery += ` AND a.source_type = $` + strconv.Itoa(listIdx)
listArgs = append(listArgs, *sourceType)
listIdx++
}
listQuery, listArgs, listIdx = appendArticleSourceTypeFilter(listQuery, listArgs, listIdx, sourceTypes)
if params.GenerationMode != nil {
listQuery += ` AND a.source_type = 'custom_generation' AND COALESCE(NULLIF(gt.input_params_json ->> 'generation_mode', ''), 'instant') = $` + strconv.Itoa(listIdx)
listArgs = append(listArgs, *params.GenerationMode)
@@ -264,6 +263,11 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
listArgs = append(listArgs, *params.TemplateID)
listIdx++
}
if params.KolPromptID != nil {
listQuery += ` AND a.kol_prompt_id = $` + strconv.Itoa(listIdx)
listArgs = append(listArgs, *params.KolPromptID)
listIdx++
}
if params.PromptRuleID != nil {
listQuery += ` AND a.prompt_rule_id = $` + strconv.Itoa(listIdx)
listArgs = append(listArgs, *params.PromptRuleID)
@@ -1009,6 +1013,55 @@ func resolveWordCount(markdown *string, stored int) int {
return stored
}
func normalizeArticleSourceTypes(sourceType *string) []string {
if sourceType == nil {
return nil
}
parts := strings.Split(*sourceType, ",")
out := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
value := strings.TrimSpace(part)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func appendArticleSourceTypeFilter(
query string,
args []interface{},
argIdx int,
sourceTypes []string,
) (string, []interface{}, int) {
if len(sourceTypes) == 0 {
return query, args, argIdx
}
placeholders := make([]string, 0, len(sourceTypes))
for _, sourceType := range sourceTypes {
placeholders = append(placeholders, `$`+strconv.Itoa(argIdx))
args = append(args, sourceType)
argIdx++
}
if len(placeholders) == 1 {
query += ` AND a.source_type = ` + placeholders[0]
} else {
query += ` AND a.source_type IN (` + strings.Join(placeholders, ", ") + `)`
}
return query, args, argIdx
}
func resolveArticleGenerationMode(sourceType string, inputParamsJSON []byte) *string {
if sourceType != "custom_generation" {
return nil
@@ -224,3 +224,35 @@ func invalidateArticleCaches(ctx context.Context, c sharedcache.Cache, tenantID
func InvalidateArticleCaches(ctx context.Context, c sharedcache.Cache, tenantID int64, articleID *int64) {
invalidateArticleCaches(ctx, c, tenantID, articleID)
}
func kolPromptDetailCachePrefix(tenantID int64) string {
return fmt.Sprintf("kol:prompt:detail:%d:", tenantID)
}
func kolPromptListCachePrefix(tenantID int64) string {
return fmt.Sprintf("kol:prompt:list:%d:", tenantID)
}
func kolSubscriptionPromptListCachePrefix(tenantID int64) string {
return fmt.Sprintf("kol:subscription_prompt:list:%d:", tenantID)
}
func kolSubscriptionPromptSchemaCachePrefix(tenantID int64) string {
return fmt.Sprintf("kol:subscription_prompt:schema:%d:", tenantID)
}
func invalidateKolPromptCaches(ctx context.Context, c sharedcache.Cache, tenantID int64) {
deleteCachePrefix(ctx, c, kolPromptDetailCachePrefix(tenantID))
deleteCachePrefix(ctx, c, kolPromptListCachePrefix(tenantID))
deleteCachePrefix(ctx, c, kolSubscriptionPromptListCachePrefix(tenantID))
deleteCachePrefix(ctx, c, kolSubscriptionPromptSchemaCachePrefix(tenantID))
deleteCacheKey(ctx, c, workspaceKolCardsCacheKey(tenantID))
}
func InvalidateKolPromptCaches(ctx context.Context, c sharedcache.Cache) {
deleteCachePrefix(ctx, c, "kol:prompt:detail:")
deleteCachePrefix(ctx, c, "kol:prompt:list:")
deleteCachePrefix(ctx, c, "kol:subscription_prompt:list:")
deleteCachePrefix(ctx, c, "kol:subscription_prompt:schema:")
deleteCachePrefix(ctx, c, "workspace:kol_cards:")
}
@@ -0,0 +1,103 @@
package app
import (
"strings"
"testing"
)
func TestExtractKnowledgePreciseFactsKeepsAddressAndContactLines(t *testing.T) {
facts := extractKnowledgePreciseFacts(`
# 合肥门店
地址:合肥市蜀山区望江西路 123 号 A 座 8 层
电话:0551-12345678
官网:https://example.com/store
普通介绍段落
`)
for _, expected := range []string{
"地址:合肥市蜀山区望江西路 123 号 A 座 8 层",
"电话:0551-12345678",
"官网:https://example.com/store",
} {
if !containsExactString(facts, expected) {
t.Fatalf("extractKnowledgePreciseFacts() = %#v, want %q", facts, expected)
}
}
}
func TestRenderPromptSectionIncludesPreciseFactsAndSourceURI(t *testing.T) {
sourceURI := "https://example.com/store"
output := (&KnowledgeService{}).RenderPromptSection(&KnowledgeContext{
Snippets: []KnowledgeSnippet{
{
GroupName: "门店信息",
ItemName: "合肥门店",
SourceURI: &sourceURI,
Text: "这里是一段门店介绍。",
PreciseFacts: []string{"地址:合肥市蜀山区望江西路 123 号", "电话:0551-12345678"},
},
},
})
for _, expected := range []string{
"以下为必须严格按原文保留的高精度事实",
"- 门店信息 / 合肥门店:地址:合肥市蜀山区望江西路 123 号",
"来源链接: https://example.com/store",
"这里是一段门店介绍。",
} {
if !strings.Contains(output, expected) {
t.Fatalf("RenderPromptSection() = %q, want %q", output, expected)
}
}
}
func TestRenderPromptSectionSupportsPreciseFactsWithoutSnippets(t *testing.T) {
output := (&KnowledgeService{}).RenderPromptSection(&KnowledgeContext{
PreciseFacts: []string{
"公司资料 / 合肥门店:地址:合肥市蜀山区望江西路 123 号",
"公司资料 / 合肥门店:主营业务:全屋定制;橱柜定制",
},
})
for _, expected := range []string{
"以下为必须严格按原文保留的高精度事实",
"- 公司资料 / 合肥门店:地址:合肥市蜀山区望江西路 123 号",
"- 公司资料 / 合肥门店:主营业务:全屋定制;橱柜定制",
} {
if !strings.Contains(output, expected) {
t.Fatalf("RenderPromptSection() = %q, want %q", output, expected)
}
}
}
func TestExtractKnowledgePreciseFactsKeepsBusinessScopeSection(t *testing.T) {
facts := extractKnowledgePreciseFacts(`
# 公司介绍
主营业务
- 全屋定制
- 橱柜定制
- 木门与护墙系统
地址:合肥市蜀山区望江西路 123 号
`)
for _, expected := range []string{
"主营业务:全屋定制;橱柜定制;木门与护墙系统",
"地址:合肥市蜀山区望江西路 123 号",
} {
if !containsExactString(facts, expected) {
t.Fatalf("extractKnowledgePreciseFacts() = %#v, want %q", facts, expected)
}
}
}
func containsExactString(items []string, target string) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}
+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)
@@ -23,7 +23,9 @@ const (
var ErrKolGenerationForbidden = response.ErrForbidden(40373, "kol_generation_forbidden", "subscription prompt is not available")
type KolGenerationSubmitRequest struct {
Variables map[string]any `json:"variables"`
Variables map[string]any `json:"variables"`
EnableWebSearch bool `json:"enable_web_search"`
KnowledgeGroupIDs []int64 `json:"knowledge_group_ids"`
}
type KolGenerationSubmitResponse struct {
@@ -48,8 +50,12 @@ type kolGenerationTaskInput struct {
SubscriptionID int64 `json:"subscription_id"`
PackageID int64 `json:"package_id"`
PromptID int64 `json:"prompt_id"`
PromptName string `json:"prompt_name"`
PlatformHint string `json:"platform_hint"`
PromptRevisionNo int `json:"prompt_revision_no"`
Variables map[string]any `json:"variables"`
EnableWebSearch bool `json:"enable_web_search"`
KnowledgeGroupIDs []int64 `json:"knowledge_group_ids"`
SchemaSnapshot json.RawMessage `json:"schema_snapshot"`
CardConfigSnapshot json.RawMessage `json:"card_config_snapshot"`
RenderedPromptHash string `json:"rendered_prompt_hash"`
@@ -125,12 +131,13 @@ func (s *KolGenerationService) GetSchema(ctx context.Context, actor auth.Actor,
}, nil
}
func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, subPromptID int64, variables map[string]any) (*KolGenerationSubmitResponse, error) {
func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, subPromptID int64, req KolGenerationSubmitRequest) (*KolGenerationSubmitResponse, error) {
row, prompt, err := s.loadAuthorizedPrompt(ctx, actor, subPromptID)
if err != nil {
return nil, err
}
variables := req.Variables
if variables == nil {
variables = map[string]any{}
}
@@ -139,6 +146,10 @@ func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, sub
if err != nil {
return nil, response.ErrInternal(50096, "kol_generation_schema_decode_failed", err.Error())
}
cardConfig, err := decodeKolCardConfig(prompt.CardConfigJSON)
if err != nil {
return nil, response.ErrInternal(50097, "kol_generation_card_config_decode_failed", err.Error())
}
if prompt.PromptAssetKey == nil || *prompt.PromptAssetKey == "" {
return nil, response.ErrInternal(50098, "kol_generation_prompt_asset_missing", "published prompt content not found")
@@ -154,6 +165,16 @@ func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, sub
return nil, response.ErrBadRequest(40071, "kol_generation_variables_invalid", err.Error())
}
renderedPromptHash := HashPrompt(renderedPrompt)
if req.EnableWebSearch && !kolCardConfigAllowsWebSearch(cardConfig) {
return nil, response.ErrBadRequest(40073, "kol_generation_web_search_disabled", "web search is disabled for this prompt")
}
knowledgeGroupIDs := normalizeInt64IDs(req.KnowledgeGroupIDs)
if len(knowledgeGroupIDs) > 0 && !kolCardConfigAllowsUserKnowledge(cardConfig) {
return nil, response.ErrBadRequest(40074, "kol_generation_knowledge_disabled", "knowledge selection is disabled for this prompt")
}
enableWebSearch := kolCardConfigAllowsWebSearch(cardConfig) && req.EnableWebSearch
promptRevisionNo := 1
if row.PublishedRevisionNo != nil && *row.PublishedRevisionNo > 0 {
promptRevisionNo = *row.PublishedRevisionNo
@@ -169,8 +190,12 @@ func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, sub
SubscriptionID: row.SubscriptionID,
PackageID: row.PackageID,
PromptID: row.PromptID,
PromptName: row.PromptName,
PlatformHint: derefString(row.PlatformHint),
PromptRevisionNo: promptRevisionNo,
Variables: variables,
EnableWebSearch: enableWebSearch,
KnowledgeGroupIDs: knowledgeGroupIDs,
SchemaSnapshot: json.RawMessage(prompt.SchemaJSON),
CardConfigSnapshot: json.RawMessage(prompt.CardConfigJSON),
RenderedPromptHash: renderedPromptHash,
@@ -7,6 +7,7 @@ import (
"time"
"github.com/geo-platform/tenant-api/internal/shared/auth"
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
@@ -44,6 +45,7 @@ type KolPackageService struct {
profileSvc *KolProfileService
repo repository.KolPackageRepository
promptRepo repository.KolPromptRepository
cache sharedcache.Cache
}
func NewKolPackageService(
@@ -58,6 +60,11 @@ func NewKolPackageService(
}
}
func (s *KolPackageService) WithCache(c sharedcache.Cache) *KolPackageService {
s.cache = c
return s
}
func (s *KolPackageService) List(ctx context.Context, actor auth.Actor) ([]KolPackageResponse, error) {
profile, err := s.profileSvc.RequireActiveKol(ctx, actor)
if err != nil {
@@ -119,6 +126,7 @@ func (s *KolPackageService) Create(ctx context.Context, actor auth.Actor, input
return nil, response.ErrInternal(50063, "kol_package_create_failed", err.Error())
}
InvalidateKolPromptCaches(ctx, s.cache)
return s.buildPackageResponse(ctx, profile, pkg)
}
@@ -158,6 +166,7 @@ func (s *KolPackageService) Update(ctx context.Context, actor auth.Actor, id int
return nil, errKolPackageNotFound
}
InvalidateKolPromptCaches(ctx, s.cache)
return s.buildPackageResponse(ctx, profile, updated)
}
@@ -179,6 +188,7 @@ func (s *KolPackageService) Publish(ctx context.Context, actor auth.Actor, id in
return nil, errKolPackageNotFound
}
InvalidateKolPromptCaches(ctx, s.cache)
return s.buildPackageResponse(ctx, profile, updated)
}
@@ -200,6 +210,7 @@ func (s *KolPackageService) Archive(ctx context.Context, actor auth.Actor, id in
return nil, errKolPackageNotFound
}
InvalidateKolPromptCaches(ctx, s.cache)
return s.buildPackageResponse(ctx, profile, updated)
}
@@ -212,6 +223,7 @@ func (s *KolPackageService) Delete(ctx context.Context, actor auth.Actor, id int
return response.ErrInternal(50067, "kol_package_delete_failed", err.Error())
}
InvalidateKolPromptCaches(ctx, s.cache)
return nil
}
@@ -10,6 +10,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/geo-platform/tenant-api/internal/shared/auth"
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
@@ -66,6 +67,7 @@ type KolPromptService struct {
promptRepo repository.KolPromptRepository
subRepo repository.KolSubscriptionRepository
asset *KolPromptAsset
cache sharedcache.Cache
}
func NewKolPromptService(
@@ -86,6 +88,11 @@ func NewKolPromptService(
}
}
func (s *KolPromptService) WithCache(c sharedcache.Cache) *KolPromptService {
s.cache = c
return s
}
func (s *KolPromptService) CreatePrompt(ctx context.Context, actor auth.Actor, input CreatePromptInput) (*KolPromptDetailResponse, error) {
if strings.TrimSpace(input.Name) == "" {
return nil, response.ErrBadRequest(40063, "kol_prompt_name_required", "prompt name is required")
@@ -107,6 +114,7 @@ func (s *KolPromptService) CreatePrompt(ctx context.Context, actor auth.Actor, i
return nil, response.ErrInternal(50070, "kol_prompt_create_failed", err.Error())
}
InvalidateKolPromptCaches(ctx, s.cache)
return s.buildPromptDetail(ctx, prompt, false)
}
@@ -176,6 +184,7 @@ func (s *KolPromptService) UpdateMetadata(ctx context.Context, actor auth.Actor,
return nil, ErrPromptNotFound
}
InvalidateKolPromptCaches(ctx, s.cache)
return s.buildPromptDetail(ctx, updated, false)
}
@@ -248,6 +257,7 @@ func (s *KolPromptService) Save(ctx context.Context, actor auth.Actor, promptID
return nil, ErrPromptNotFound
}
InvalidateKolPromptCaches(ctx, s.cache)
return s.buildPromptDetail(ctx, updated, true)
}
@@ -330,6 +340,7 @@ func (s *KolPromptService) Publish(ctx context.Context, actor auth.Actor, prompt
return nil, ErrPromptNotFound
}
InvalidateKolPromptCaches(ctx, s.cache)
return s.buildPromptDetail(ctx, updated, true)
}
@@ -373,6 +384,7 @@ func (s *KolPromptService) Activate(ctx context.Context, actor auth.Actor, promp
return nil, ErrPromptNotFound
}
InvalidateKolPromptCaches(ctx, s.cache)
return s.buildPromptDetail(ctx, updated, false)
}
@@ -393,6 +405,7 @@ func (s *KolPromptService) Archive(ctx context.Context, actor auth.Actor, prompt
return nil, ErrPromptNotFound
}
InvalidateKolPromptCaches(ctx, s.cache)
return s.buildPromptDetail(ctx, updated, false)
}
@@ -405,6 +418,7 @@ func (s *KolPromptService) Delete(ctx context.Context, actor auth.Actor, promptI
return response.ErrInternal(50082, "kol_prompt_delete_failed", err.Error())
}
InvalidateKolPromptCaches(ctx, s.cache)
return nil
}
@@ -520,10 +534,36 @@ func (s *KolPromptService) grantPromptToActiveSubscribers(
}
func normalizeKolCardConfig(cardConfig map[string]interface{}) map[string]interface{} {
if cardConfig == nil {
return map[string]interface{}{}
normalized := map[string]interface{}{
"allow_web_search": false,
"allow_user_knowledge": true,
}
return cardConfig
for key, value := range cardConfig {
normalized[key] = value
}
normalized["allow_web_search"] = kolCardConfigBoolValue(cardConfig["allow_web_search"], false)
normalized["allow_user_knowledge"] = kolCardConfigBoolValue(cardConfig["allow_user_knowledge"], true)
return normalized
}
func kolCardConfigBoolValue(value interface{}, defaultValue bool) bool {
switch typed := value.(type) {
case bool:
return typed
case *bool:
if typed != nil {
return *typed
}
}
return defaultValue
}
func kolCardConfigAllowsWebSearch(cardConfig map[string]interface{}) bool {
return kolCardConfigBoolValue(cardConfig["allow_web_search"], false)
}
func kolCardConfigAllowsUserKnowledge(cardConfig map[string]interface{}) bool {
return kolCardConfigBoolValue(cardConfig["allow_user_knowledge"], true)
}
func decodeKolSchema(raw []byte) (*KolSchemaJSON, error) {
@@ -542,7 +582,7 @@ func decodeKolSchema(raw []byte) (*KolSchemaJSON, error) {
func decodeKolCardConfig(raw []byte) (map[string]interface{}, error) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return map[string]interface{}{}, nil
return normalizeKolCardConfig(nil), nil
}
var cardConfig map[string]interface{}
@@ -8,6 +8,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
"github.com/geo-platform/tenant-api/internal/shared/middleware"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
@@ -25,6 +26,7 @@ type KolSubscriptionAdminService struct {
marketplaceRepo repository.KolMarketplaceRepository
subRepo repository.KolSubscriptionRepository
promptRepo repository.KolPromptRepository
cache sharedcache.Cache
}
func NewKolSubscriptionAdminService(
@@ -43,6 +45,11 @@ func NewKolSubscriptionAdminService(
}
}
func (s *KolSubscriptionAdminService) WithCache(c sharedcache.Cache) *KolSubscriptionAdminService {
s.cache = c
return s
}
func (s *KolSubscriptionAdminService) Approve(ctx context.Context, id int64, endAt *time.Time, operatorID int64) (*KolSubscriptionResponse, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
@@ -89,6 +96,7 @@ func (s *KolSubscriptionAdminService) Approve(ctx context.Context, id int64, end
return nil, response.ErrInternal(50098, "kol_subscription_approve_commit_failed", "failed to commit subscription approval")
}
InvalidateKolPromptCaches(ctx, s.cache)
s.logAdminAction(ctx, "approve", operatorID, updated.TenantID, updated.ID, map[string]interface{}{
"id": updated.ID,
"tenant_id": updated.TenantID,
@@ -157,6 +165,7 @@ func (s *KolSubscriptionAdminService) ManualBind(ctx context.Context, input Manu
return nil, response.ErrInternal(50101, "kol_subscription_bind_commit_failed", "failed to commit manual bind")
}
InvalidateKolPromptCaches(ctx, s.cache)
s.logAdminAction(ctx, "manual_bind", operatorID, updated.TenantID, updated.ID, map[string]interface{}{
"id": updated.ID,
"tenant_id": updated.TenantID,
@@ -194,6 +203,7 @@ func (s *KolSubscriptionAdminService) Revoke(ctx context.Context, id int64, oper
return response.ErrInternal(50105, "kol_subscription_revoke_commit_failed", "failed to commit subscription revoke")
}
InvalidateKolPromptCaches(ctx, s.cache)
s.logAdminAction(ctx, "revoke", operatorID, sub.TenantID, sub.ID, map[string]interface{}{
"id": sub.ID,
"tenant_id": sub.TenantID,
@@ -74,7 +74,7 @@ func (h *ArticleHandler) List(c *gin.Context) {
if v := c.Query("publish_status"); v != "" {
params.PublishStatus = &v
}
if v := c.Query("source_type"); v != "" {
if v := normalizeSourceTypeParam(c.Query("source_type")); v != "" {
params.SourceType = &v
}
if v := c.Query("generation_mode"); v != "" {
@@ -85,6 +85,11 @@ func (h *ArticleHandler) List(c *gin.Context) {
params.TemplateID = &id
}
}
if v := c.Query("kol_prompt_id"); v != "" {
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
params.KolPromptID = &id
}
}
if v := c.Query("prompt_rule_id"); v != "" {
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
params.PromptRuleID = &id
@@ -118,6 +123,29 @@ func (h *ArticleHandler) List(c *gin.Context) {
response.Success(c, data)
}
func normalizeSourceTypeParam(raw string) string {
if strings.TrimSpace(raw) == "" {
return ""
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
value := strings.TrimSpace(part)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return strings.Join(out, ",")
}
func (h *ArticleHandler) GenerateFromRule(c *gin.Context) {
var req app.GenerateFromRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -29,7 +29,7 @@ func NewKolAdminHandler(a *bootstrap.App) *KolAdminHandler {
a.KolMarketplace,
a.KolSubscriptions,
a.KolPrompts,
),
).WithCache(a.Cache),
}
}
@@ -23,8 +23,8 @@ func NewKolManageHandler(a *bootstrap.App) *KolManageHandler {
return &KolManageHandler{
profileSvc: profileSvc,
pkgSvc: app.NewKolPackageService(profileSvc, a.KolPackages, a.KolPrompts),
promptSvc: app.NewKolPromptService(a.DB, profileSvc, a.KolPackages, a.KolPrompts, a.KolSubscriptions, a.KolPromptAsset),
pkgSvc: app.NewKolPackageService(profileSvc, a.KolPackages, a.KolPrompts).WithCache(a.Cache),
promptSvc: app.NewKolPromptService(a.DB, profileSvc, a.KolPackages, a.KolPrompts, a.KolSubscriptions, a.KolPromptAsset).WithCache(a.Cache),
assistSvc: app.NewKolAssistService(profileSvc, a.KolAssists, a.LLM, a.RabbitMQ),
}
}
@@ -162,7 +162,7 @@ func (h *KolMarketplaceHandler) Generate(c *gin.Context) {
return
}
data, err := h.genSvc.Submit(c.Request.Context(), actor, id, req.Variables)
data, err := h.genSvc.Submit(c.Request.Context(), actor, id, req)
if err != nil {
response.Error(c, err)
return
@@ -3,6 +3,8 @@ package generate
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
@@ -23,6 +25,7 @@ const kolGenerationTaskType = "kol"
type KolGenerationWorker struct {
pool *pgxpool.Pool
llm llm.Client
knowledge *tenantapp.KnowledgeService
logger *zap.Logger
articleTimeout time.Duration
maxOutputTokens int64
@@ -32,6 +35,7 @@ type KolGenerationWorker struct {
func NewKolGenerationWorker(
pool *pgxpool.Pool,
llmClient llm.Client,
knowledgeSvc *tenantapp.KnowledgeService,
logger *zap.Logger,
cfg config.GenerationConfig,
llmMaxOutputTokens int64,
@@ -44,6 +48,7 @@ func NewKolGenerationWorker(
return &KolGenerationWorker{
pool: pool,
llm: llmClient,
knowledge: knowledgeSvc,
logger: logger,
articleTimeout: articleTimeout,
maxOutputTokens: llmMaxOutputTokens,
@@ -84,11 +89,41 @@ func (w *KolGenerationWorker) Process(ctx context.Context, task tenantapp.Claime
return
}
result, err := w.llm.Generate(ctx, llm.GenerateRequest{
Prompt: renderedPrompt,
knowledgePrompt := ""
if knowledgeGroupIDs := kolKnowledgeGroupIDs(task.InputParams["knowledge_group_ids"]); len(knowledgeGroupIDs) > 0 {
if w.knowledge == nil {
w.HandleTaskFailure(ctx, task, "knowledge_resolve", fmt.Errorf("knowledge service is not configured"))
return
}
knowledgeContext, err := w.knowledge.ResolveContext(
ctx,
task.TenantID,
knowledgeGroupIDs,
buildKolKnowledgeQuery(task.InputParams),
)
if err != nil {
w.HandleTaskFailure(ctx, task, "knowledge_resolve", err)
return
}
knowledgePrompt = w.knowledge.RenderPromptSection(knowledgeContext)
}
prompt := renderedPrompt
if strings.TrimSpace(knowledgePrompt) != "" {
prompt = strings.TrimSpace(renderedPrompt + "\n\n" + knowledgePrompt)
}
generateReq := llm.GenerateRequest{
Prompt: prompt,
Timeout: w.articleTimeout,
MaxOutputTokens: w.maxOutputTokens,
}, nil)
}
if kolBoolInput(task.InputParams, "enable_web_search") {
generateReq.WebSearch = &llm.WebSearchOptions{Enabled: true}
}
result, err := w.llm.Generate(ctx, generateReq, nil)
if err != nil {
w.HandleTaskFailure(ctx, task, "llm_generate", err)
return
@@ -216,6 +251,137 @@ func kolStringInput(input map[string]interface{}, key string) string {
return text
}
func kolBoolInput(input map[string]interface{}, key string) bool {
if input == nil {
return false
}
value, ok := input[key]
if !ok || value == nil {
return false
}
enabled, ok := value.(bool)
return ok && enabled
}
func kolKnowledgeGroupIDs(value interface{}) []int64 {
switch typed := value.(type) {
case []int64:
return dedupeKolIDs(typed)
case []interface{}:
ids := make([]int64, 0, len(typed))
for _, item := range typed {
switch raw := item.(type) {
case int64:
ids = append(ids, raw)
case int32:
ids = append(ids, int64(raw))
case int:
ids = append(ids, int64(raw))
case float64:
ids = append(ids, int64(raw))
case float32:
ids = append(ids, int64(raw))
}
}
return dedupeKolIDs(ids)
default:
return nil
}
}
func dedupeKolIDs(ids []int64) []int64 {
if len(ids) == 0 {
return nil
}
seen := make(map[int64]struct{}, len(ids))
result := make([]int64, 0, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
sort.Slice(result, func(i, j int) bool {
return result[i] < result[j]
})
return result
}
func buildKolKnowledgeQuery(input map[string]interface{}) string {
parts := make([]string, 0, 8)
if promptName := strings.TrimSpace(kolStringInput(input, "prompt_name")); promptName != "" {
parts = append(parts, promptName)
}
if platformHint := strings.TrimSpace(kolStringInput(input, "platform_hint")); platformHint != "" {
parts = append(parts, platformHint)
}
variables, _ := input["variables"].(map[string]interface{})
if len(variables) > 0 {
keys := make([]string, 0, len(variables))
for key := range variables {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
valueText := strings.TrimSpace(kolValueString(variables[key]))
if valueText == "" {
continue
}
parts = append(parts, fmt.Sprintf("%s: %s", key, valueText))
}
}
if len(parts) == 0 {
if renderedPrompt := strings.TrimSpace(kolStringInput(input, "rendered_prompt")); renderedPrompt != "" {
parts = append(parts, renderedPrompt)
}
}
return kolLimitText(strings.Join(parts, "\n"), 2000)
}
func kolValueString(value interface{}) string {
switch typed := value.(type) {
case string:
return typed
case bool:
if typed {
return "true"
}
return "false"
case []interface{}:
items := make([]string, 0, len(typed))
for _, item := range typed {
text := strings.TrimSpace(kolValueString(item))
if text != "" {
items = append(items, text)
}
}
return strings.Join(items, ", ")
default:
return fmt.Sprintf("%v", typed)
}
}
func kolLimitText(value string, maxRunes int) string {
text := strings.TrimSpace(value)
if text == "" || maxRunes <= 0 {
return text
}
runes := []rune(text)
if len(runes) <= maxRunes {
return text
}
return strings.TrimSpace(string(runes[:maxRunes]))
}
func kolResolveArticleTitle(markdown string) string {
for _, line := range strings.Split(markdown, "\n") {
line = strings.TrimSpace(line)