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 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, } }