package viral import ( "context" "encoding/json" "fmt" "sort" "strings" "haixun-backend/internal/library/placement" ) type MissionInspireInput struct { PersonaDisplayName string PersonaBrief string PersonaBlock string StyleBenchmark string PersonaAudience string PersonaContentGoal string PersonaQuestions []string PersonaPillars []string RecentMissionLabels []string RecentSeedQueries []string UserKeyword string TrendSnippets []MissionInspireTrendSnippet WebSearchProvider string LLMOnly bool } type MissionInspireTrendSnippet struct { Query string Title string Snippet string URL string } type MissionInspireThreadsInput struct { Keyword string PersonaBrief string StyleBenchmark string Pillars []string Questions []string Member placement.MemberContext Crawler placement.CrawlerSearchFn } type MissionInspireOutput struct { Label string SeedQuery string Brief string TrendReason string TrendKeywords []string } func BuildMissionInspireSystemPrompt() string { return strings.TrimSpace(`你是 Threads 拷貝忍者的「靈感骰子」顧問。根據近期 Threads 熱門貼文、網路搜尋訊號與創作者人設,產出一組**全新**拷貝任務草稿。 規則: 1. 若【近期趨勢訊號】有內容,從中挑一個適合在 Threads 海巡的方向;不要編造不存在的時事或熱門貼文 2. 若趨勢訊號為空(未連線網路搜尋),**必須**改依人設、受眾痛點與常見 Threads 討論型態推測「近期可能被搜尋」的話題,並在 trendReason 說明推測理由(不要假裝有外部熱搜來源) 3. label:任務名稱,6~18 字,像企劃案標題,不要標點堆疊 4. seedQuery:種子關鍵字/近期熱詞,2~6 個詞用頓號或空格分隔,適合當 Threads 搜尋起點 5. brief:這次想找什麼,2~4 句,說明要海巡哪類爆款、語氣或格式偏好 6. trendReason:1~2 句,為何選這個趨勢(可引用訊號來源大意,不要貼 URL) 7. trendKeywords:3~6 個相關熱詞(字串陣列) 8. 避開【近期已做過的任務】相同題材 9. 繁體中文;只回傳 JSON: {"label":"","seedQuery":"","brief":"","trendReason":"","trendKeywords":[]}`) } func BuildMissionInspireUserPrompt(in MissionInspireInput) string { var b strings.Builder if name := strings.TrimSpace(in.PersonaDisplayName); name != "" { b.WriteString("【人設名稱】") b.WriteString(name) b.WriteString("\n") } if p := strings.TrimSpace(in.PersonaBlock); p != "" { b.WriteString("【人設摘要】\n") b.WriteString(p) b.WriteString("\n") } if bench := strings.TrimSpace(in.StyleBenchmark); bench != "" { b.WriteString("【對標帳號】@") b.WriteString(strings.TrimPrefix(bench, "@")) b.WriteString("\n") } if aud := strings.TrimSpace(in.PersonaAudience); aud != "" { b.WriteString("【受眾方向】") b.WriteString(aud) b.WriteString("\n") } if goal := strings.TrimSpace(in.PersonaContentGoal); goal != "" { b.WriteString("【內容目標】") b.WriteString(goal) b.WriteString("\n") } if len(in.PersonaPillars) > 0 { b.WriteString("【內容支柱】") b.WriteString(strings.Join(in.PersonaPillars, "、")) b.WriteString("\n") } if keyword := strings.TrimSpace(in.UserKeyword); keyword != "" { b.WriteString("【使用者指定靈感關鍵字】") b.WriteString(keyword) b.WriteString("\n") } if len(in.RecentMissionLabels) > 0 || len(in.RecentSeedQueries) > 0 { b.WriteString("【近期已做過的任務(請避開)】\n") for _, label := range in.RecentMissionLabels { if label = strings.TrimSpace(label); label != "" { b.WriteString("- ") b.WriteString(label) b.WriteString("\n") } } for _, seed := range in.RecentSeedQueries { if seed = strings.TrimSpace(seed); seed != "" { b.WriteString("- 種子:") b.WriteString(seed) b.WriteString("\n") } } } if in.LLMOnly { b.WriteString("【模式】未取得 Threads/API/搜尋素材,請純依人設、使用者關鍵字與受眾推測靈感(勿假裝有外部熱搜)\n") } else if provider := strings.TrimSpace(in.WebSearchProvider); provider != "" { b.WriteString("【趨勢來源】") b.WriteString(provider) b.WriteString("\n") } b.WriteString("【近期趨勢訊號】\n") if len(in.TrendSnippets) == 0 { b.WriteString("(無外部趨勢結果,請改用人設推測近期 Threads 可能熱議方向)\n") } else { for i, item := range in.TrendSnippets { b.WriteString(fmt.Sprintf("[%d] 查詢:%s\n", i+1, strings.TrimSpace(item.Query))) if title := strings.TrimSpace(item.Title); title != "" { b.WriteString("標題:") b.WriteString(title) b.WriteString("\n") } if snippet := strings.TrimSpace(item.Snippet); snippet != "" { b.WriteString(snippet) b.WriteString("\n") } b.WriteString("\n") } } b.WriteString("請產出拷貝任務靈感 JSON。") return b.String() } func InspireTrendSearchQueries(personaBrief, styleBenchmark string) []string { queries := []string{ "Google Trends 台灣 今日 熱門搜尋 關鍵字", "Threads 台灣 熱門 話題 最近 一週", "近期 網路 熱搜 關鍵字 台灣", } context := strings.TrimSpace(personaBrief + " " + styleBenchmark) if context != "" { trimmed := context if len([]rune(trimmed)) > 24 { trimmed = string([]rune(trimmed)[:24]) } queries = append(queries, trimmed+" 熱門 話題 最近") } return queries } func InspireThreadsSearchQueries(in MissionInspireThreadsInput) []string { seen := map[string]struct{}{} out := make([]string, 0, 6) add := func(q string) { q = cleanInspireQuery(q) if q == "" { return } if _, ok := seen[q]; ok { return } seen[q] = struct{}{} out = append(out, q) } keyword := cleanInspireQuery(in.Keyword) if keyword != "" { add(keyword) add(keyword + " 心情") add(keyword + " 經驗") add(keyword + " 請問") } for _, pillar := range in.Pillars { if len(out) >= 6 { break } add(pillar) if keyword != "" { add(keyword + " " + pillar) } } for _, question := range in.Questions { if len(out) >= 6 { break } add(question) } if len(out) == 0 { context := strings.TrimSpace(in.PersonaBrief + " " + in.StyleBenchmark) if len([]rune(context)) > 24 { context = string([]rune(context)[:24]) } add(context) } if len(out) > 6 { return out[:6] } return out } func CollectMissionInspireThreadsTrends(ctx context.Context, in MissionInspireThreadsInput) []MissionInspireTrendSnippet { if !in.Member.HasDiscoverPath() { return nil } if !in.Member.AllowsCrawler && !in.Member.AllowsThreadsAPI { return nil } queries := InspireThreadsSearchQueries(in) if len(queries) == 0 { return nil } type scoredSnippet struct { item MissionInspireTrendSnippet score int } seen := map[string]struct{}{} collected := make([]scoredSnippet, 0, 12) for _, query := range queries { posts, _, err := placement.Discover(ctx, placement.DiscoverRequest{ Query: query, Keyword: query, Limit: 8, Member: in.Member, Crawler: in.Crawler, }) if err != nil { continue } for _, post := range posts { text := strings.TrimSpace(post.Text) if len([]rune(text)) < 18 { continue } key := strings.TrimSpace(post.Permalink) if key == "" { key = strings.TrimSpace(post.ExternalID) } if key == "" { key = text } if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} title := strings.TrimSpace(post.Author) if title != "" { title = "Threads @" + strings.TrimPrefix(title, "@") } else { title = "Threads 熱門貼文" } collected = append(collected, scoredSnippet{ item: MissionInspireTrendSnippet{ Query: query, Title: title, Snippet: shortenRunes(text, 180), URL: strings.TrimSpace(post.Permalink), }, score: post.LikeCount + post.ReplyCount*2, }) } if len(collected) >= 12 { break } } sort.SliceStable(collected, func(i, j int) bool { return collected[i].score > collected[j].score }) if len(collected) > 8 { collected = collected[:8] } out := make([]MissionInspireTrendSnippet, 0, len(collected)) for _, item := range collected { out = append(out, item.item) } return out } func cleanInspireQuery(raw string) string { raw = strings.TrimSpace(raw) raw = strings.ReplaceAll(raw, "\n", " ") raw = strings.Join(strings.Fields(raw), " ") if len([]rune(raw)) > 24 { raw = string([]rune(raw)[:24]) } return raw } func shortenRunes(raw string, max int) string { raw = strings.TrimSpace(raw) if max <= 0 || len([]rune(raw)) <= max { return raw } return string([]rune(raw)[:max]) + "…" } func ParseMissionInspireOutput(raw string) (MissionInspireOutput, error) { payload, err := extractCopyMapJSON(raw) if err != nil { return MissionInspireOutput{}, err } var root map[string]json.RawMessage if err := json.Unmarshal(payload, &root); err != nil { return MissionInspireOutput{}, err } out := MissionInspireOutput{ Label: firstJSONString(root, "label", "title", "mission_label"), SeedQuery: firstJSONString(root, "seedQuery", "seed_query", "seed", "keywords"), Brief: firstJSONString(root, "brief", "description", "goal"), TrendReason: firstJSONString(root, "trendReason", "trend_reason", "reason"), } if out.Label == "" || out.SeedQuery == "" || out.Brief == "" { return MissionInspireOutput{}, fmt.Errorf("missing label, seedQuery, or brief") } if rawKW, ok := root["trendKeywords"]; ok { out.TrendKeywords = parseFlexibleStringList(rawKW) } if len(out.TrendKeywords) == 0 { if rawKW, ok := root["trend_keywords"]; ok { out.TrendKeywords = parseFlexibleStringList(rawKW) } } return out, nil }