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

410 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
AvoidTopics []string
ContentDirection 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
Angles []string
Mission string
TargetAudience string
OpeningType string
BodyType string
Emotion string
CtaType string
RiskLevel string
Avoid []string
}
func BuildMissionInspireSystemPrompt() string {
return strings.TrimSpace(`你是 Threads 帳號的內容編輯與 Topic Radar。根據近期 Threads / 社群討論訊號、網路搜尋素材與創作者人設,先產出「可執行的內容計畫」,不要直接寫貼文正文。
規則:
1. 若【近期趨勢訊號】有內容,從中挑一個適合在 Threads 海巡的方向;優先使用社群熱門關鍵字、受眾求助、心得討論、留言常見問題,不要把新聞事件本身當主題
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. 若使用者有指定關鍵字label、seedQuery、brief 都必須保留該方向,不可漂移成無關日常題
22. 繁體中文;只回傳 JSON
23. 避免新聞導向:不要產出「某事件最新進展」「政策新聞懶人包」「名人/品牌新聞」這類題目;除非它已經轉化成受眾正在問的生活問題或經驗討論
{"label":"","seedQuery":"","brief":"","trendReason":"","trendKeywords":[],"angles":[],"mission":"","targetAudience":"","openingType":"","bodyType":"","emotion":"","ctaType":"","riskLevel":"low","avoid":[]}`)
}
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注意不要把人設裡的名詞、興趣或裝飾符號直接變成話題。\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 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 {
b.WriteString("【近期已做過的任務(請避開)】\n")
for _, topic := range in.AvoidTopics {
if topic = strings.TrimSpace(topic); topic != "" {
b.WriteString("- ")
b.WriteString(topic)
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("請產出 Topic Radar 與 Content Plan JSON。")
return b.String()
}
func InspireTrendSearchQueries(personaBrief, styleBenchmark string) []string {
queries := []string{
"Threads 台灣 熱門 關鍵字 討論 最近 一週",
"Threads 台灣 請問 推薦 心得 最近",
"社群 熱門 關鍵字 台灣 討論 Dcard PTT Threads",
}
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",
)
}
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 + " 請問")
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"),
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"),
}
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)
}
}
return out, nil
}