thread-master/backend/internal/library/viral/mission_inspire.go

345 lines
9.7 KiB
Go
Raw Permalink Normal View History

2026-06-26 08:37:04 +00:00
package viral
import (
2026-06-28 08:28:42 +00:00
"context"
2026-06-26 08:37:04 +00:00
"encoding/json"
"fmt"
2026-06-28 08:28:42 +00:00
"sort"
2026-06-26 08:37:04 +00:00
"strings"
2026-06-28 08:28:42 +00:00
"haixun-backend/internal/library/placement"
2026-06-26 08:37:04 +00:00
)
type MissionInspireInput struct {
PersonaDisplayName string
PersonaBrief string
PersonaBlock string
StyleBenchmark string
PersonaAudience string
PersonaContentGoal string
PersonaQuestions []string
PersonaPillars []string
RecentMissionLabels []string
RecentSeedQueries []string
2026-06-28 08:28:42 +00:00
UserKeyword string
2026-06-26 08:37:04 +00:00
TrendSnippets []MissionInspireTrendSnippet
WebSearchProvider string
LLMOnly bool
}
type MissionInspireTrendSnippet struct {
Query string
Title string
Snippet string
URL string
}
2026-06-28 08:28:42 +00:00
type MissionInspireThreadsInput struct {
Keyword string
PersonaBrief string
StyleBenchmark string
Pillars []string
Questions []string
Member placement.MemberContext
Crawler placement.CrawlerSearchFn
}
2026-06-26 08:37:04 +00:00
type MissionInspireOutput struct {
Label string
SeedQuery string
Brief string
TrendReason string
TrendKeywords []string
}
func BuildMissionInspireSystemPrompt() string {
2026-06-28 08:28:42 +00:00
return strings.TrimSpace(`你是 Threads 拷貝忍者的靈感骰子顧問根據近期 Threads 熱門貼文網路搜尋訊號與創作者人設產出一組**全新**拷貝任務草稿
2026-06-26 08:37:04 +00:00
規則
2026-06-28 08:28:42 +00:00
1. 近期趨勢訊號有內容從中挑一個適合在 Threads 海巡的方向不要編造不存在的時事或熱門貼文
2026-06-26 08:37:04 +00:00
2. 若趨勢訊號為空未連線網路搜尋**必須**改依人設受眾痛點與常見 Threads 討論型態推測近期可能被搜尋的話題並在 trendReason 說明推測理由不要假裝有外部熱搜來源
3. label任務名稱618 像企劃案標題不要標點堆疊
4. seedQuery種子關鍵字近期熱詞26 個詞用頓號或空格分隔適合當 Threads 搜尋起點
5. brief這次想找什麼24 說明要海巡哪類爆款語氣或格式偏好
6. trendReason12 為何選這個趨勢可引用訊號來源大意不要貼 URL
7. trendKeywords36 個相關熱詞字串陣列
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")
}
2026-06-28 08:28:42 +00:00
if keyword := strings.TrimSpace(in.UserKeyword); keyword != "" {
b.WriteString("【使用者指定靈感關鍵字】")
b.WriteString(keyword)
b.WriteString("\n")
}
2026-06-26 08:37:04 +00:00
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 {
2026-06-28 08:28:42 +00:00
b.WriteString("【模式】未取得 Threads/API/搜尋素材,請純依人設、使用者關鍵字與受眾推測靈感(勿假裝有外部熱搜)\n")
2026-06-26 08:37:04 +00:00
} else if provider := strings.TrimSpace(in.WebSearchProvider); provider != "" {
b.WriteString("【趨勢來源】")
b.WriteString(provider)
2026-06-28 08:28:42 +00:00
b.WriteString("\n")
2026-06-26 08:37:04 +00:00
}
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
}
2026-06-28 08:28:42 +00:00
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]) + "…"
}
2026-06-26 08:37:04 +00:00
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
}