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:
@@ -68,6 +68,7 @@ func main() {
|
||||
kolWorker := generateworker.NewKolGenerationWorker(
|
||||
app.DB,
|
||||
app.LLM,
|
||||
knowledgeSvc,
|
||||
app.Logger,
|
||||
generationCfg,
|
||||
app.Config.LLM.MaxOutputTokens,
|
||||
|
||||
@@ -16,7 +16,7 @@ runtime:
|
||||
- 保持客观、克制、非广告化,不使用夸张宣传语、绝对化表述或暗示性背书,如“顶级”“第一”“首选”“最靠谱”“权威认证”“唯一”“零投诉”等。
|
||||
- 如涉及品牌、公司、机构或服务商比较,默认按信息整理顺序展开,不构成排名、评奖或唯一推荐;不要刻意贬低同行。
|
||||
- 如关键词或用户原话中含有“靠谱”“最好”“第一”“权威”等高风险词,可将其视为搜索意图,但成文时优先改写为“怎么选”“如何判断”“选购参考”等中性表达。
|
||||
- 不输出联系方式、购买引导、广告标识、治疗/收益保证或其他容易触发平台审核风险的表述。
|
||||
- 非必要时不要输出联系方式、购买引导、广告标识、治疗/收益保证或其他容易触发平台审核风险的表述;若用户明确要求,或知识库中已提供且确需引用门店地址、官网、邮箱、电话、营业时间、主营业务、经营范围、服务范围、核心产品等信息,只能严格依据已提供内容,不得改写成不存在的业务,不得混写、推测或补全。
|
||||
- 使用清晰的小节标题和短段落;仅在确实有助于理解时使用列表。
|
||||
- 结尾应给出清晰结论、适合对象、不适合对象、信息边界或下一步建议,帮助读者完成判断。
|
||||
generation_template_specific_rules_heading: "模板专项要求:"
|
||||
@@ -210,7 +210,7 @@ runtime:
|
||||
4. 保持客观中立,避免排名、绝对化承诺、权威背书、夸大宣传和刻意贬损同行。
|
||||
knowledge_prompt_intro_lines:
|
||||
- "以下为可参考的知识库内容,只用于补充事实背景、术语定义、产品信息或表达素材:"
|
||||
- "- 优先吸收并改写,不要逐段照抄。"
|
||||
- "- 优先吸收并改写,但地址、电话、官网、邮箱、营业时间、价格、日期,以及主营业务、经营范围、服务范围、核心产品等业务事实如需引用,必须严格依据原文,不得写成知识库里没有的业务内容。"
|
||||
- "- 若知识库内容与用户明确输入冲突,以用户输入为准。"
|
||||
- "- 信息不足时不要虚构事实。"
|
||||
knowledge_snippet_label_format: "知识片段 %d"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user