diff --git a/server/internal/tenant/app/kol_assist_generate_context.go b/server/internal/tenant/app/kol_assist_generate_context.go new file mode 100644 index 0000000..55276e2 --- /dev/null +++ b/server/internal/tenant/app/kol_assist_generate_context.go @@ -0,0 +1,190 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + + "github.com/geo-platform/tenant-api/internal/shared/config" + "go.uber.org/zap" +) + +const kolAssistReferenceContentMaxRunes = 6000 + +var kolAssistURLPattern = regexp.MustCompile(`https?://[^\s]+`) + +type kolAssistURLResolver struct { + arkBaseURL string + arkAPIKey string + httpClient *http.Client + logger *zap.Logger +} + +type kolAssistReferenceMaterial struct { + URL string + Title string + Content string +} + +func newKolAssistURLResolver(llmCfg config.LLMConfig, logger *zap.Logger) *kolAssistURLResolver { + baseURL := strings.TrimSpace(llmCfg.BaseURL) + if baseURL == "" { + baseURL = defaultKnowledgeArkBaseURL + } + + return &kolAssistURLResolver{ + arkBaseURL: strings.TrimRight(baseURL, "/"), + arkAPIKey: strings.TrimSpace(llmCfg.APIKey), + httpClient: &http.Client{Timeout: defaultKnowledgeURLParseTimeout}, + logger: logger, + } +} + +func (r *kolAssistURLResolver) extract(ctx context.Context, rawURL string) (*kolAssistReferenceMaterial, error) { + if r == nil || r.httpClient == nil || strings.TrimSpace(r.arkAPIKey) == "" { + return nil, fmt.Errorf("url resolver is unavailable") + } + + body, err := json.Marshal(arkToolExecuteRequest{ + ActionName: "LinkReader", + ToolName: "LinkReader", + Timeout: int(defaultKnowledgeURLParseTimeout.Seconds()), + Parameters: map[string]any{ + "url_list": []string{rawURL}, + }, + }) + if err != nil { + return nil, fmt.Errorf("marshal webpage parser request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.arkBaseURL+"/tools/execute", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create webpage parser request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+r.arkAPIKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := r.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute webpage parser: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= http.StatusBadRequest { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, fmt.Errorf("webpage parser failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(data))) + } + + var parsed arkToolExecuteResponse + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return nil, fmt.Errorf("decode webpage parser response: %w", err) + } + if parsed.StatusCode != 0 && parsed.StatusCode != http.StatusOK { + return nil, fmt.Errorf("webpage parser returned status_code=%d", parsed.StatusCode) + } + if len(parsed.Data.ArkWebDataList) == 0 { + return nil, fmt.Errorf("webpage parser returned no content") + } + + item := parsed.Data.ArkWebDataList[0] + if strings.TrimSpace(item.ErrorCode) != "" || strings.TrimSpace(item.ErrorMessage) != "" { + return nil, fmt.Errorf("webpage parser returned error: %s %s", strings.TrimSpace(item.ErrorCode), strings.TrimSpace(item.ErrorMessage)) + } + + content := truncateKolAssistReferenceContent(normalizeKnowledgeText(item.Content)) + if content == "" { + return nil, fmt.Errorf("webpage parser returned empty content") + } + + return &kolAssistReferenceMaterial{ + URL: strings.TrimSpace(rawURL), + Title: strings.TrimSpace(item.Title), + Content: content, + }, nil +} + +func (s *KolAssistService) enrichGenerateDescription(ctx context.Context, description string) string { + description = strings.TrimSpace(description) + urls := extractKolAssistURLs(description) + if len(urls) == 0 || s == nil || s.urlResolver == nil { + return description + } + + references := make([]kolAssistReferenceMaterial, 0, len(urls)) + for _, rawURL := range urls { + material, err := s.urlResolver.extract(ctx, rawURL) + if err != nil { + if s.logger != nil { + s.logger.Warn("kol assist url extraction failed", + zap.String("url", rawURL), + zap.Error(err), + ) + } + continue + } + references = append(references, *material) + } + + if len(references) == 0 { + return description + } + + var builder strings.Builder + builder.WriteString(description) + builder.WriteString("\n\n以下是系统从参考网页提取到的正文内容。你必须基于这些内容学习文章的方法、结构、标题策略和风格,但不要复述具体事实,不要直接照抄原文。\n") + for index, item := range references { + builder.WriteString(fmt.Sprintf("\n[参考网页 %d]\nURL: %s\n", index+1, item.URL)) + if item.Title != "" { + builder.WriteString("标题: ") + builder.WriteString(item.Title) + builder.WriteString("\n") + } + builder.WriteString("正文摘录:\n") + builder.WriteString(item.Content) + builder.WriteString("\n") + } + + return builder.String() +} + +func extractKolAssistURLs(input string) []string { + matches := kolAssistURLPattern.FindAllString(input, -1) + if len(matches) == 0 { + return nil + } + + result := make([]string, 0, len(matches)) + seen := make(map[string]struct{}, len(matches)) + for _, match := range matches { + candidate := strings.TrimSpace(match) + candidate = strings.Trim(candidate, `"'<>[](){}`) + candidate = strings.TrimRight(candidate, ".,;,。;、)]}》」”") + if candidate == "" { + continue + } + if _, exists := seen[candidate]; exists { + continue + } + seen[candidate] = struct{}{} + result = append(result, candidate) + } + return result +} + +func truncateKolAssistReferenceContent(content string) string { + content = strings.TrimSpace(content) + if content == "" { + return "" + } + + runes := []rune(content) + if len(runes) <= kolAssistReferenceContentMaxRunes { + return content + } + return strings.TrimSpace(string(runes[:kolAssistReferenceContentMaxRunes])) +} diff --git a/server/internal/tenant/app/kol_assist_runtime.go b/server/internal/tenant/app/kol_assist_runtime.go index 8dfd870..4acd6a0 100644 --- a/server/internal/tenant/app/kol_assist_runtime.go +++ b/server/internal/tenant/app/kol_assist_runtime.go @@ -43,6 +43,9 @@ func (s *KolAssistService) Run( if err != nil { return nil, "", err } + if req.Mode == kolAssistModeGenerate { + req.Description = s.enrichGenerateDescription(ctx, req.Description) + } generateReq, err := BuildKolAssistGenerateRequest(req) if err != nil { @@ -88,7 +91,21 @@ func BuildKolAssistGenerateRequest(req AssistRequest) (llm.GenerateRequest, erro func BuildKolAssistUserPrompt(req AssistRequest) (string, error) { switch strings.ToLower(strings.TrimSpace(req.Mode)) { case kolAssistModeGenerate: - return req.Description, nil + return strings.TrimSpace(fmt.Sprintf(` +你的任务是把“用户需求 + 参考材料”转换成“最终可直接给另一个 AI 使用的提示词模板”。 + +严格要求: +1. 只输出最终提示词模板,不要输出分析、总结、复盘、学习笔记、解释说明。 +2. 如果提供了参考文章或参考网页,只学习其选题切入、标题策略、结构框架、叙事节奏、风格语气、互动与转化方式,然后抽象成可复用规则。 +3. 任何会变化的信息都必须参数化成 {{变量名}} 占位符,不要把具体的人名、品牌、平台、标题、数字、时间、地区、产品、案例、链接、经历写死。 +4. 若用户基于具体文章/网页生成模板,结果中至少包含 3 个 {{}} 占位符。 +5. 结果必须是“生成同类内容”的模板,不是对参考文章的分析。 +6. 模板至少覆盖:创作目标、选题要求、标题要求、结构要求、风格要求、互动/转化要求、限制项。 +7. 输出中文,直接给出最终模板正文。 + +用户需求与参考材料: +%s +`, req.Description)), nil case kolAssistModeOptimize: schemaJSON := "null" if req.Schema != nil { diff --git a/server/internal/tenant/app/kol_assist_service.go b/server/internal/tenant/app/kol_assist_service.go index efe8505..a33f2d0 100644 --- a/server/internal/tenant/app/kol_assist_service.go +++ b/server/internal/tenant/app/kol_assist_service.go @@ -8,10 +8,12 @@ import ( "github.com/google/uuid" "github.com/geo-platform/tenant-api/internal/shared/auth" + "github.com/geo-platform/tenant-api/internal/shared/config" "github.com/geo-platform/tenant-api/internal/shared/llm" "github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq" "github.com/geo-platform/tenant-api/internal/shared/response" "github.com/geo-platform/tenant-api/internal/tenant/repository" + "go.uber.org/zap" ) const ( @@ -46,10 +48,12 @@ type KolAssistTaskResponse struct { } type KolAssistService struct { - profileSvc *KolProfileService - repo repository.KolAssistRepository - llm llm.Client - messaging *rabbitmq.Client + profileSvc *KolProfileService + repo repository.KolAssistRepository + llm llm.Client + messaging *rabbitmq.Client + logger *zap.Logger + urlResolver *kolAssistURLResolver } func NewKolAssistService( @@ -57,12 +61,16 @@ func NewKolAssistService( repo repository.KolAssistRepository, llmClient llm.Client, messagingClient *rabbitmq.Client, + llmCfg config.LLMConfig, + logger *zap.Logger, ) *KolAssistService { return &KolAssistService{ - profileSvc: profileSvc, - repo: repo, - llm: llmClient, - messaging: messagingClient, + profileSvc: profileSvc, + repo: repo, + llm: llmClient, + messaging: messagingClient, + logger: logger, + urlResolver: newKolAssistURLResolver(llmCfg, logger), } } diff --git a/server/internal/tenant/transport/kol_manage_handler.go b/server/internal/tenant/transport/kol_manage_handler.go index 29a5925..12411cc 100644 --- a/server/internal/tenant/transport/kol_manage_handler.go +++ b/server/internal/tenant/transport/kol_manage_handler.go @@ -42,7 +42,7 @@ func NewKolManageHandler(a *bootstrap.App, assets *AssetHandler) *KolManageHandl profileSvc: profileSvc, 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), + assistSvc: app.NewKolAssistService(profileSvc, a.KolAssists, a.LLM, a.RabbitMQ, a.Config.LLM, a.Logger), assets: assets, } }