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
@@ -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)