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

410 lines
14 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
AvoidTopics []string
ContentDirection 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
Angles []string
Mission string
TargetAudience string
OpeningType string
BodyType string
Emotion string
CtaType string
RiskLevel string
Avoid []string
2026-06-26 08:37:04 +00:00
}
func BuildMissionInspireSystemPrompt() string {
return strings.TrimSpace(`你是 Threads 帳號的內容編輯與 Topic Radar根據近期 Threads / 社群討論訊號網路搜尋素材與創作者人設先產出可執行的內容計畫不要直接寫貼文正文
2026-06-26 08:37:04 +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 個相關熱詞字串陣列必須像 Threads 會搜尋/討論的關鍵字不要像新聞標題
8. angles35 個可勾選切角字串陣列每個切角都要說明這篇要怎麼切不要直接寫成完整貼文
9. mission內容任務只能是日常陪伴專業知識情緒共鳴工具清單熱門觀點互動提問之一除非使用者方向要求更精準
10. targetAudience這篇真正要對誰說越具體越好
11. openingType開頭策略例如問題開場場景開場反直覺開場經驗開場清單開場
12. bodyType中段結構例如先共鳴再整理問題拆解步驟清單案例對照觀點辯證
13. emotion情緒槓桿例如焦慮被接住想被理解怕踩雷想準備好想被鼓勵
14. ctaType互動/收藏/私訊等 CTA 型態不要硬業配
15. riskLevellow / medium / high醫療財務法律政策名人爭議等至少 medium
16. avoid36 個禁忌或不要寫偏的方向字串陣列
17. 避開近期已做過的任務相同題材
18. 起號優先題目要能連續延伸 510 不要只是一則熱搜心得
19. 除非使用者關鍵字人設或趨勢訊號明確要求不要產出早餐早安日常飲食純生活流水帳這類泛題
20. 人設介紹只用來判斷語氣與受眾不是題材庫不可因人設寫 Y2K咖啡穿搭音樂就把靈感主題漂移成咖啡穿搭或復古日常
21. 若使用者有指定關鍵字labelseedQuerybrief 都必須保留該方向不可漂移成無關日常題
22. 繁體中文只回傳 JSON
23. 避免新聞導向不要產出某事件最新進展政策新聞懶人包名人/品牌新聞這類題目除非它已經轉化成受眾正在問的生活問題或經驗討論
{"label":"","seedQuery":"","brief":"","trendReason":"","trendKeywords":[],"angles":[],"mission":"","targetAudience":"","openingType":"","bodyType":"","emotion":"","ctaType":"","riskLevel":"low","avoid":[]}`)
2026-06-26 08:37:04 +00:00
}
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")
2026-06-26 08:37:04 +00:00
b.WriteString(p)
b.WriteString("\n注意不要把人設裡的名詞、興趣或裝飾符號直接變成話題。\n")
2026-06-26 08:37:04 +00:00
}
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")
}
if direction := strings.TrimSpace(in.ContentDirection); direction != "" {
b.WriteString("【使用者選擇內容方向】")
b.WriteString(direction)
b.WriteString("\n請讓 label、brief、angles 都符合這個方向;如果方向是 auto請依熱度與人設自動選。\n")
}
if len(in.AvoidTopics) > 0 || len(in.RecentMissionLabels) > 0 || len(in.RecentSeedQueries) > 0 {
2026-06-26 08:37:04 +00:00
b.WriteString("【近期已做過的任務(請避開)】\n")
for _, topic := range in.AvoidTopics {
if topic = strings.TrimSpace(topic); topic != "" {
b.WriteString("- ")
b.WriteString(topic)
b.WriteString("\n")
}
}
2026-06-26 08:37:04 +00:00
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("請產出 Topic Radar 與 Content Plan JSON。")
2026-06-26 08:37:04 +00:00
return b.String()
}
func InspireTrendSearchQueries(personaBrief, styleBenchmark string) []string {
queries := []string{
"Threads 台灣 熱門 關鍵字 討論 最近 一週",
"Threads 台灣 請問 推薦 心得 最近",
"社群 熱門 關鍵字 台灣 討論 Dcard PTT Threads",
2026-06-26 08:37:04 +00:00
}
context := strings.TrimSpace(personaBrief + " " + styleBenchmark)
if context != "" {
trimmed := context
if len([]rune(trimmed)) > 24 {
trimmed = string([]rune(trimmed)[:24])
}
queries = append(queries,
trimmed+" Threads 熱門 關鍵字 討論",
trimmed+" 請問 心得 推薦 Threads",
)
2026-06-26 08:37:04 +00:00
}
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 + " 心得")
2026-06-28 08:28:42 +00:00
add(keyword + " 請問")
add(keyword + " 推薦")
add(keyword + " 怎麼辦")
2026-06-28 08:28:42 +00:00
}
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"),
Mission: firstJSONString(root, "mission", "contentMission", "content_mission"),
TargetAudience: firstJSONString(root, "targetAudience", "target_audience", "audience"),
OpeningType: firstJSONString(root, "openingType", "opening_type", "hookType", "hook_type"),
BodyType: firstJSONString(root, "bodyType", "body_type", "structure", "bodyStructure"),
Emotion: firstJSONString(root, "emotion", "emotionalLever", "emotional_lever"),
CtaType: firstJSONString(root, "ctaType", "cta_type", "cta"),
RiskLevel: firstJSONString(root, "riskLevel", "risk_level", "risk"),
2026-06-26 08:37:04 +00:00
}
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)
}
}
if rawAngles, ok := root["angles"]; ok {
out.Angles = parseFlexibleStringList(rawAngles)
}
if len(out.Angles) == 0 {
if rawAngles, ok := root["contentAngles"]; ok {
out.Angles = parseFlexibleStringList(rawAngles)
}
}
if rawAvoid, ok := root["avoid"]; ok {
out.Avoid = parseFlexibleStringList(rawAvoid)
}
if len(out.Avoid) == 0 {
if rawAvoid, ok := root["avoidTopics"]; ok {
out.Avoid = parseFlexibleStringList(rawAvoid)
}
}
2026-06-26 08:37:04 +00:00
return out, nil
}