144 lines
5.6 KiB
Go
144 lines
5.6 KiB
Go
package viral
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
)
|
||
|
||
// MissionResearchMap is the structured copy-mission research map (single LLM call).
|
||
type MissionResearchMap struct {
|
||
AudienceSummary string `json:"audienceSummary"`
|
||
ContentGoal string `json:"contentGoal"`
|
||
Questions []string `json:"questions"`
|
||
Pillars []string `json:"pillars"`
|
||
Exclusions []string `json:"exclusions"`
|
||
SuggestedTags []SuggestedTag `json:"suggestedTags"`
|
||
BenchmarkNotes string `json:"benchmarkNotes"`
|
||
}
|
||
|
||
func BuildMissionResearchMapSystemPrompt() string {
|
||
return strings.TrimSpace(`你是 Threads 爆款/對標研究顧問。目標:幫創作者找到「近期熱門、高互動、值得仿寫」的話題與搜尋方向。
|
||
|
||
規則:
|
||
1. audienceSummary:必填,2~4 句描述「受眾是誰」(年齡/情境/痛點/會在 Threads 搜什麼),不要只寫人設本人
|
||
2. 聚焦「最近會在 Threads 被搜、被討論」的話題,不要寫成學術報告
|
||
3. contentGoal:找到近期互動佳、結構可模仿的爆款貼文
|
||
4. pillars:可模仿方向(語錄型、故事型、清單型等),至少 4 個;**必須是字串陣列**,不要用 {title:...} 物件
|
||
5. questions:受眾會搜的短問題,5+ 個;字串陣列
|
||
6. exclusions:不要模仿的內容,至少 4 個;字串陣列
|
||
7. suggestedTags:6~8 個「像真人會在 Threads 搜尋框打的字」
|
||
- 每個含 tag, reason, searchIntent(痛點|知識|經驗|對比|工具|語錄), searchType(短詞|情境|語錄)
|
||
- tag 2~10 字,不要標點、不要完整句子、不要像文章標題
|
||
8. benchmarkNotes:怎樣算值得仿的爆款(互動、hook 清楚)
|
||
9. 繁體中文;只回傳 JSON(含 audienceSummary)`)
|
||
}
|
||
|
||
func BuildMissionResearchMapUserPrompt(in CopyResearchMapInput) string {
|
||
var b strings.Builder
|
||
b.WriteString("【任務名稱】")
|
||
b.WriteString(strings.TrimSpace(in.Label))
|
||
b.WriteString("\n【種子關鍵字/近期熱詞】")
|
||
b.WriteString(strings.TrimSpace(in.SeedQuery))
|
||
b.WriteString("\n【這次想找什麼】\n")
|
||
b.WriteString(strings.TrimSpace(in.Brief))
|
||
if p := strings.TrimSpace(in.Persona); p != "" {
|
||
b.WriteString("\n【我的人設(仿寫時會套用,地圖請對準受眾與話題,不要只寫我自己)】\n")
|
||
b.WriteString(p)
|
||
}
|
||
if bench := strings.TrimSpace(in.StyleBenchmark); bench != "" {
|
||
b.WriteString("\n【長期對標帳號參考】@")
|
||
b.WriteString(strings.TrimPrefix(bench, "@"))
|
||
b.WriteString("\n")
|
||
}
|
||
if aud := strings.TrimSpace(in.PersonaAudienceSummary); aud != "" {
|
||
b.WriteString("\n【人設層級受眾研究(請延伸為本次任務受眾,勿只複製)】\n")
|
||
b.WriteString(aud)
|
||
if goal := strings.TrimSpace(in.PersonaContentGoal); goal != "" {
|
||
b.WriteString("\n內容目標參考:")
|
||
b.WriteString(goal)
|
||
}
|
||
if len(in.PersonaQuestions) > 0 {
|
||
b.WriteString("\n受眾提問參考:")
|
||
b.WriteString(strings.Join(in.PersonaQuestions, "、"))
|
||
}
|
||
if len(in.PersonaPillars) > 0 {
|
||
b.WriteString("\n內容支柱參考:")
|
||
b.WriteString(strings.Join(in.PersonaPillars, "、"))
|
||
}
|
||
b.WriteString("\n")
|
||
}
|
||
b.WriteString("\n請產出拷貝任務研究地圖 JSON(必填 audienceSummary 與 suggestedTags 陣列)。")
|
||
return b.String()
|
||
}
|
||
|
||
func ParseMissionResearchMapOutput(raw string) (MissionResearchMap, error) {
|
||
payload, err := extractCopyMapJSON(raw)
|
||
if err != nil {
|
||
return MissionResearchMap{}, err
|
||
}
|
||
var root map[string]json.RawMessage
|
||
if err := json.Unmarshal(payload, &root); err != nil {
|
||
return MissionResearchMap{}, fmt.Errorf("parse mission research map: %w", err)
|
||
}
|
||
out := MissionResearchMap{
|
||
AudienceSummary: firstJSONString(root, "audienceSummary", "audience_summary"),
|
||
ContentGoal: firstJSONString(root, "contentGoal", "content_goal"),
|
||
BenchmarkNotes: firstJSONString(root, "benchmarkNotes", "benchmark_notes"),
|
||
Questions: parseFlexibleStringList(pickRawMessage(root, "questions")),
|
||
Pillars: parseFlexibleStringList(pickRawMessage(root, "pillars")),
|
||
Exclusions: parseFlexibleStringList(pickRawMessage(root, "exclusions")),
|
||
SuggestedTags: parseFlexibleSuggestedTags(pickRawMessage(root, "suggestedTags", "suggested_tags")),
|
||
}
|
||
if strings.TrimSpace(out.AudienceSummary) == "" {
|
||
return MissionResearchMap{}, fmt.Errorf("mission research map missing audienceSummary")
|
||
}
|
||
if len(out.SuggestedTags) < 4 {
|
||
return MissionResearchMap{}, fmt.Errorf("mission research map needs at least 4 suggested tags")
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func cleanSuggestedTags(tags []SuggestedTag) []SuggestedTag {
|
||
out := []SuggestedTag{}
|
||
seen := map[string]struct{}{}
|
||
for _, item := range tags {
|
||
tag := strings.TrimSpace(item.Tag)
|
||
if tag == "" {
|
||
continue
|
||
}
|
||
if _, ok := seen[tag]; ok {
|
||
continue
|
||
}
|
||
seen[tag] = struct{}{}
|
||
out = append(out, SuggestedTag{
|
||
Tag: tag,
|
||
Reason: strings.TrimSpace(item.Reason),
|
||
SearchIntent: strings.TrimSpace(item.SearchIntent),
|
||
SearchType: strings.TrimSpace(item.SearchType),
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func ToEntityMissionResearchMap(m MissionResearchMap) map[string]any {
|
||
tags := make([]map[string]any, 0, len(m.SuggestedTags))
|
||
for _, item := range m.SuggestedTags {
|
||
tags = append(tags, map[string]any{
|
||
"tag": item.Tag,
|
||
"reason": item.Reason,
|
||
"search_intent": item.SearchIntent,
|
||
"search_type": item.SearchType,
|
||
})
|
||
}
|
||
return map[string]any{
|
||
"audience_summary": m.AudienceSummary,
|
||
"content_goal": m.ContentGoal,
|
||
"questions": m.Questions,
|
||
"pillars": m.Pillars,
|
||
"exclusions": m.Exclusions,
|
||
"suggested_tags": tags,
|
||
"benchmark_notes": m.BenchmarkNotes,
|
||
}
|
||
}
|