thread-master/internal/library/viral/research_map.go

141 lines
4.8 KiB
Go
Raw Permalink Normal View History

2026-06-26 08:37:04 +00:00
package viral
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
type CopyResearchMap struct {
AudienceSummary string `json:"audienceSummary"`
ContentGoal string `json:"contentGoal"`
Questions []string `json:"questions"`
Pillars []string `json:"pillars"`
Exclusions []string `json:"exclusions"`
SuggestedTags []string `json:"suggestedTags"`
BenchmarkNotes string `json:"benchmarkNotes"`
}
type CopyResearchMapInput struct {
Label string
SeedQuery string
Brief string
Persona string
StyleBenchmark string
PersonaAudienceSummary string
PersonaContentGoal string
PersonaQuestions []string
PersonaPillars []string
}
var copyMapFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```")
func BuildCopyResearchMapSystemPrompt() string {
return strings.TrimSpace(`你是 Threads 爆款對標研究顧問目標是幫創作者找到高互動值得仿寫的參考貼文與對標方向
規則
1. audienceSummary必填24 句描述受眾是誰情境痛點會搜什麼
2. contentGoal 要寫找到近期互動佳結構可模仿的爆款貼文分析 hook節奏文案公式
3. pillars可模仿的內容方向語錄型故事型清單型等至少 4
4. questions受眾會搜尋關心的短問題5+ 適合當爆款掃描關鍵字
5. exclusions不要模仿的內容業配純晒照無結構閒聊等至少 4
6. suggestedTags24 字短詞10 個左右用於 Threads 搜尋爆款
7. benchmarkNotes一句話說明怎樣算值得仿的爆款互動留言品質hook 清楚
8. 繁體中文只回傳 JSONaudienceSummary, contentGoal, questions, pillars, exclusions, suggestedTags, benchmarkNotes`)
}
func BuildCopyResearchMapUserPrompt(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【Brief】\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")
}
b.WriteString("\n請產出拷貝忍者研究地圖 JSON。")
return b.String()
}
func ParseCopyResearchMapOutput(raw string) (CopyResearchMap, error) {
payload, err := extractCopyMapJSON(raw)
if err != nil {
return CopyResearchMap{}, err
}
var root map[string]json.RawMessage
if err := json.Unmarshal(payload, &root); err != nil {
return CopyResearchMap{}, fmt.Errorf("parse copy research map: %w", err)
}
tagObjs := parseFlexibleSuggestedTags(pickRawMessage(root, "suggestedTags", "suggested_tags"))
tagStrs := make([]string, 0, len(tagObjs))
for _, item := range tagObjs {
if item.Tag != "" {
tagStrs = append(tagStrs, item.Tag)
}
}
out := CopyResearchMap{
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: cleanLines(tagStrs),
}
if strings.TrimSpace(out.AudienceSummary) == "" {
return CopyResearchMap{}, fmt.Errorf("copy research map missing audienceSummary")
}
return out, nil
}
func ToEntityResearchMap(m CopyResearchMap) map[string]any {
return map[string]any{
"audience_summary": m.AudienceSummary,
"content_goal": m.ContentGoal,
"questions": m.Questions,
"pillars": m.Pillars,
"exclusions": m.Exclusions,
"suggested_tags": m.SuggestedTags,
"benchmark_notes": m.BenchmarkNotes,
}
}
func cleanLines(items []string) []string {
out := make([]string, 0, len(items))
seen := map[string]struct{}{}
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
out = append(out, item)
}
return out
}
func extractCopyMapJSON(raw string) ([]byte, error) {
raw = strings.TrimSpace(raw)
if m := copyMapFenceRE.FindStringSubmatch(raw); len(m) == 2 {
raw = strings.TrimSpace(m[1])
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start < 0 || end <= start {
return nil, fmt.Errorf("copy research map missing json")
}
return []byte(raw[start : end+1]), nil
}