410 lines
14 KiB
Go
410 lines
14 KiB
Go
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:任務名稱,6~18 字,像企劃案標題,不要標點堆疊
|
||
4. seedQuery:種子關鍵字/近期熱詞,2~6 個詞用頓號或空格分隔,適合當 Threads 搜尋起點
|
||
5. brief:這次內容計畫摘要,2~4 句,說明話題、受眾痛點、為何適合這個帳號
|
||
6. trendReason:1~2 句,為何選這個趨勢(說明它和受眾討論、搜尋意圖或痛點的關係,不要貼 URL)
|
||
7. trendKeywords:3~6 個相關熱詞(字串陣列),必須像 Threads 會搜尋/討論的關鍵字,不要像新聞標題
|
||
8. angles:3~5 個可勾選切角(字串陣列),每個切角都要說明「這篇要怎麼切」,不要直接寫成完整貼文
|
||
9. mission:內容任務,只能是「日常陪伴」「專業知識」「情緒共鳴」「工具清單」「熱門觀點」「互動提問」之一,除非使用者方向要求更精準
|
||
10. targetAudience:這篇真正要對誰說,越具體越好
|
||
11. openingType:開頭策略,例如「問題開場」「場景開場」「反直覺開場」「經驗開場」「清單開場」
|
||
12. bodyType:中段結構,例如「先共鳴再整理」「問題拆解」「步驟清單」「案例對照」「觀點辯證」
|
||
13. emotion:情緒槓桿,例如「焦慮被接住」「想被理解」「怕踩雷」「想準備好」「想被鼓勵」
|
||
14. ctaType:互動/收藏/私訊等 CTA 型態,不要硬業配
|
||
15. riskLevel:low / medium / high;醫療、財務、法律、政策、名人爭議等至少 medium
|
||
16. avoid:3~6 個禁忌或不要寫偏的方向(字串陣列)
|
||
17. 避開【近期已做過的任務】相同題材
|
||
18. 起號優先:題目要能連續延伸 5~10 篇,不要只是一則熱搜心得
|
||
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
|
||
}
|