feat(kol): enrich assist generate with URL references and template prompt
- Add kolAssistURLResolver that fetches reference articles via the Ark
URL extractor when the user's description contains http(s) links, and
merges up to kolAssistReferenceContentMaxRunes of extracted content
into the generate description.
- Rewrite the generate user prompt into a strict template-authoring
brief: output only the final prompt template in Chinese, parameterize
variable info as {{placeholder}}, require at least 3 placeholders
when references exist, and cover goal/topic/title/structure/style/
interaction/constraints.
- Wire LLMConfig and logger through NewKolAssistService and the
handler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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]))
|
||||||
|
}
|
||||||
@@ -43,6 +43,9 @@ func (s *KolAssistService) Run(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
if req.Mode == kolAssistModeGenerate {
|
||||||
|
req.Description = s.enrichGenerateDescription(ctx, req.Description)
|
||||||
|
}
|
||||||
|
|
||||||
generateReq, err := BuildKolAssistGenerateRequest(req)
|
generateReq, err := BuildKolAssistGenerateRequest(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -88,7 +91,21 @@ func BuildKolAssistGenerateRequest(req AssistRequest) (llm.GenerateRequest, erro
|
|||||||
func BuildKolAssistUserPrompt(req AssistRequest) (string, error) {
|
func BuildKolAssistUserPrompt(req AssistRequest) (string, error) {
|
||||||
switch strings.ToLower(strings.TrimSpace(req.Mode)) {
|
switch strings.ToLower(strings.TrimSpace(req.Mode)) {
|
||||||
case kolAssistModeGenerate:
|
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:
|
case kolAssistModeOptimize:
|
||||||
schemaJSON := "null"
|
schemaJSON := "null"
|
||||||
if req.Schema != nil {
|
if req.Schema != nil {
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
"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/llm"
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
"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/shared/response"
|
||||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -46,10 +48,12 @@ type KolAssistTaskResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type KolAssistService struct {
|
type KolAssistService struct {
|
||||||
profileSvc *KolProfileService
|
profileSvc *KolProfileService
|
||||||
repo repository.KolAssistRepository
|
repo repository.KolAssistRepository
|
||||||
llm llm.Client
|
llm llm.Client
|
||||||
messaging *rabbitmq.Client
|
messaging *rabbitmq.Client
|
||||||
|
logger *zap.Logger
|
||||||
|
urlResolver *kolAssistURLResolver
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewKolAssistService(
|
func NewKolAssistService(
|
||||||
@@ -57,12 +61,16 @@ func NewKolAssistService(
|
|||||||
repo repository.KolAssistRepository,
|
repo repository.KolAssistRepository,
|
||||||
llmClient llm.Client,
|
llmClient llm.Client,
|
||||||
messagingClient *rabbitmq.Client,
|
messagingClient *rabbitmq.Client,
|
||||||
|
llmCfg config.LLMConfig,
|
||||||
|
logger *zap.Logger,
|
||||||
) *KolAssistService {
|
) *KolAssistService {
|
||||||
return &KolAssistService{
|
return &KolAssistService{
|
||||||
profileSvc: profileSvc,
|
profileSvc: profileSvc,
|
||||||
repo: repo,
|
repo: repo,
|
||||||
llm: llmClient,
|
llm: llmClient,
|
||||||
messaging: messagingClient,
|
messaging: messagingClient,
|
||||||
|
logger: logger,
|
||||||
|
urlResolver: newKolAssistURLResolver(llmCfg, logger),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func NewKolManageHandler(a *bootstrap.App, assets *AssetHandler) *KolManageHandl
|
|||||||
profileSvc: profileSvc,
|
profileSvc: profileSvc,
|
||||||
pkgSvc: app.NewKolPackageService(profileSvc, a.KolPackages, a.KolPrompts).WithCache(a.Cache),
|
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),
|
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,
|
assets: assets,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user