155 lines
6.7 KiB
Go
155 lines
6.7 KiB
Go
package viral
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"haixun-backend/internal/library/llmjson"
|
||
)
|
||
|
||
// 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. 必須保留使用者原始題目的核心主詞、情境與目的。例如「備孕男生要吃什麼保健品」不可簡化成「老公吃什麼」
|
||
3. contentGoal:用延伸知識與人設產出可發的 Threads 內容;爆款貼文只作為可選參考,不是必經來源
|
||
4. pillars:延伸知識支柱,至少 4 個;**必須是字串陣列**,每項要能支撐一篇內容,例如營養素、檢查、生活習慣、常見迷思
|
||
5. questions:受眾會搜尋或想問的完整問題,5+ 個;字串陣列,需保留主詞與情境
|
||
6. exclusions:不要寫的內容,至少 4 個;包含過度醫療承諾、偏方、恐嚇、與題目無關的家庭日常
|
||
7. suggestedTags:6~8 個「搜尋查詢」,不是短 hashtag
|
||
- 每個含 tag, reason, searchIntent(痛點|知識|經驗|對比|工具|帳號), searchType(查詢|情境|帳號)
|
||
- tag 6~22 字,必須保留核心意圖;可以像真人搜尋句,例如「備孕男生保健品」「精蟲品質吃什麼」「男性備孕葉酸鋅」
|
||
- 不要輸出「老公吃什麼」「多閱讀」這種失去主題的泛詞
|
||
8. benchmarkNotes:整理延伸知識重點與人工看相似帳號時的觀察方向;可列 3~5 個重點
|
||
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。請把 suggestedTags 當成完整搜尋查詢,不要壓成短 hashtag。")
|
||
return b.String()
|
||
}
|
||
|
||
func MissionResearchMapJSONOnlyRetryPrompt() string {
|
||
return strings.TrimSpace(`上次回覆沒有包含可解析的 JSON。請**只**輸出一個 JSON 物件:
|
||
- 直接以 { 開頭、以 } 結尾,不要任何思考過程、說明文字或 markdown 圍欄
|
||
- 需含 audienceSummary、contentGoal、questions、pillars、exclusions、suggestedTags、benchmarkNotes 欄位
|
||
- suggestedTags 至少 4 項,每項含 tag/reason/searchIntent/searchType
|
||
- 不要使用 ... 省略,也不要加註解或尾端逗號`)
|
||
}
|
||
|
||
func ParseMissionResearchMapOutput(raw string) (MissionResearchMap, error) {
|
||
payload, err := extractCopyMapJSON(raw)
|
||
if err != nil {
|
||
return MissionResearchMap{}, err
|
||
}
|
||
var root map[string]json.RawMessage
|
||
if err := llmjson.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,
|
||
}
|
||
}
|