package placement import ( "encoding/json" "fmt" "regexp" "strings" "unicode/utf8" ) type ResearchMap struct { AudienceSummary string `json:"audienceSummary"` ContentGoal string `json:"contentGoal"` Questions []string `json:"questions"` Pillars []string `json:"pillars"` Exclusions []string `json:"exclusions"` PatrolKeywords []string `json:"patrolKeywords"` } type ResearchMapInput struct { BrandDisplayName string TopicName string SeedQuery string Brief string Goals string TargetAudience string ProductContext string ProductBrief string ProductLabel string } const ( minResearchQuestions = 8 minResearchPillars = 6 minResearchExclusions = 8 minAudienceRunes = 80 minContentGoalRunes = 50 ) var researchMapFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```") func BuildResearchMapAnalysisSystemPrompt() string { return strings.TrimSpace(`你是 Threads(脆)產品置入研究顧問。請先閱讀使用者提供的**品牌、置入產品、主題目標**等輸入,完成結構化分析。 ## 任務 - 整合所有輸入,想清楚受眾是誰、他們在煩什麼、產品能解決什麼、什麼貼文值得留言置入 - **本步驟只輸出分析文字,不要輸出 JSON** ## 請依序回答(每段 2~4 句,繁體中文,具體可執行) 1. **品牌與產品**:這個品牌/產品能解決什麼具體困擾?關鍵賣點是什麼? 2. **受眾輪廓**:誰、在什麼生活/治療情境、會因什麼症狀發文求助? 3. **求助句型方向**:Threads 上可能出現哪些求助句(列 6~10 個方向,尚未定稿) 4. **內容方向與排除**:適合找文的支柱主題、必須排除的貼文類型(排除項只寫內容類型,不寫發文時間) 5. **置入策略**:contentGoal 應點名的完整產品名稱、近期發文窗口、留言置入時機 分析必須緊扣輸入資料,不可套用與品牌/產品/主題無關的通用範例。`) } func BuildResearchMapAnalysisUserPrompt(in ResearchMapInput) string { var b strings.Builder b.WriteString("以下為本次置入主題的完整輸入,請先分析:\n\n") PlacementTopicContext{ BrandDisplayName: in.BrandDisplayName, TopicName: in.TopicName, SeedQuery: in.SeedQuery, Brief: in.Brief, Goals: in.Goals, TargetAudience: in.TargetAudience, ProductContext: in.ProductContext, ProductBrief: in.ProductBrief, ProductLabel: in.ProductLabel, }.WritePromptBlock(&b) return b.String() } func BuildResearchMapFinalizeUserPrompt(in ResearchMapInput, analysis string) string { var b strings.Builder b.WriteString("【輸入資料】\n") PlacementTopicContext{ BrandDisplayName: in.BrandDisplayName, TopicName: in.TopicName, SeedQuery: in.SeedQuery, Brief: in.Brief, Goals: in.Goals, TargetAudience: in.TargetAudience, ProductContext: in.ProductContext, ProductBrief: in.ProductBrief, ProductLabel: in.ProductLabel, }.WritePromptBlock(&b) analysis = strings.TrimSpace(analysis) if analysis != "" { b.WriteString("\n【前置分析】\n") b.WriteString(analysis) b.WriteString("\n") } b.WriteString(` --- 請依【前置分析】與【輸入資料】產出**完整**研究地圖 JSON(不可精簡),密度請對齊系統 prompt 中的優秀範例: 1. 每個欄位必須反映本次品牌、產品與使用者輸入,不可照搬範例的主題用字 2. audienceSummary 寫清楚「誰、治療/生活情境、具體困擾、在找什麼產品特徵」 3. contentGoal 要寫入【產品詳情】的完整產品名稱、近期發文、留言置入時機 4. questions 至少 8 句、pillars 至少 6 句、exclusions 至少 8 句(exclusions 只寫貼文類型,**不要寫時間/近期/幾天內**) 5. questions 是「人會發文的求助句」;patrolKeywords 是「人會打進 Threads 搜尋框的短句」,兩者不可混在一起 6. patrolKeywords 至少 6 組、最多 8 組,每組 6~16 字,從 questions 提煉搜尋短句,必須可直接拿去搜尋`) return b.String() } func BuildResearchMapSystemPrompt() string { return strings.TrimSpace(`你是 Threads(脆)產品置入研究顧問。目標是幫品牌找到「近期發文、作者有需求、現在留言還來得及自然推薦產品」的貼文——不是找爆款來模仿發文。 ## 工作流程 - 你會收到【輸入資料】(品牌、產品、主題目標等)與【前置分析】 - 請先理解分析與輸入的對應關係,再產出研究地圖 JSON - 產出必須緊扣本次品牌與產品,不可套用與輸入無關的通用內容 ## 產出密度(重要) - 這份研究地圖要給真人閱讀與執行,**寧可詳盡也不要過度精簡** - 每個欄位都要寫滿規格下限;不可用單一關鍵字、標籤語或一句話敷衍 - 只回傳一個 JSON 物件,不要 markdown 說明 ## 核心原則 1. questions 與 pillars 會直接拿去 Threads 找貼文——這兩項是最重要的產出 2. 置入時間窗口最重要:優先「近期」求助帖,作者有困擾、留言區還能自然推薦 3. 海巡要能盡量找到可置入貼文,但寧可少給也不要跑題 4. 複合主題不可拆成過寬單字(寵物洗毛精 ≠ 只搜「寵物」「狗狗」) 5. 全部繁體中文,貼近台灣 Threads 使用者口語 ## audienceSummary(3~5 句,至少 80 字) - 具體描述:誰、生活情境、正在煩什麼、為什麼會發文求助、跟產品有什麼關係 - 不要寫「注重保養的消費者」這類空泛句 ## contentGoal(2~3 句,至少 50 字) - 明確寫:找到「近期發文(理想 3 天內)」、作者本身有產品可解決的需求、留言區還能自然推薦的貼文 - **必須寫入【產品置入】中的產品名稱**(含品類與關鍵賣點,如「抗敏無香沐浴露」),不可用「目標產品」等泛稱代替 - 強調「現在就能留言置入還來得及且不突兀」,不是模仿發文或內容企劃 ## questions(至少 8 個,每句 8~24 字) - 像真人會在 Threads 打的求助句,帶治療階段、困擾、求推薦、請益、經驗分享 - 要涵蓋不同角度(症狀、能不能用、品牌推薦、挑選經驗、康復後換品) - 差:「癌症保養」「沐浴乳」這種單一關鍵字 ## pillars(至少 6 個,每句 6~22 字) - 允許的內容方向,用來找貼文並過濾結果;要比 questions 更偏主題詞組 - 差:「保養」「健康」這種過寬詞 ## exclusions(至少 8 個,每句 8~28 字) - 觸及即排除的**貼文類型/內容方向**;要寫清楚「為什麼不能置入」(純晒照、業配、無求助、跑題癌別、錯品類、已滿意他牌、非病友視角等) - **禁止寫時間相關條件**:不要碰不是篩發文時間用的。不可出現「過舊貼文」「非近期發文」「3 天前」「一週以上」「發文太久」等——置入時間窗口只寫在 contentGoal ## patrolKeywords(6~8 組,每組 6~14 字) - 這不是分類標籤,而是**真人會貼進 Threads 搜尋框的短句** - 每組 2~4 個詞,空格分隔;必須同時保留「情境/困擾」與「產品品類/用途」 - 優先格式:「困擾 品類」、「族群 品類 推薦」、「症狀 品類 請問」,例如「化療 沐浴乳 推薦」「無香 洗衣精 請問」 - 要能找到成果:過短、過廣、太像標籤的不要給(差:「癌症」「沐浴」「健康生活」「環境荷爾蒙」) - 不要整句複製 questions,也不要寫品牌名;搜尋句要像使用者會查「有沒有人在討論」的字 ## 優秀範例(請接近此密度、具體度與欄位長度) { "audienceSummary": "因荷爾蒙相關癌症(如乳癌、婦科癌症)正在治療中或康復後,對香精與化學成分特別敏感,洗澡或清潔時容易因香味而噁心、頭痛或皮膚不適,因此積極尋找「完全無香、抗敏、有第三方認證」沐浴與清潔產品的人。", "contentGoal": "找出「近期發文(理想 3 天內)」、作者本身因癌症或荷爾蒙治療導致對香味/化學成分敏感、有明確換沐浴乳或清潔產品需求、現在留言區自然推薦 ecostore 抗敏無香沐浴露還來得及且不突兀的貼文。", "questions": [ "化療後皮膚敏感要換什麼沐浴乳", "乳癌治療中不能用有香味的沐浴乳嗎", "癌症病人適合用的無香沐浴乳推薦", "荷爾蒙治療皮膚乾癢怎麼挑沐浴乳", "打標靶後對香味很敏感怎麼辦", "康復後不想再用有香精的清潔用品", "癌症病友都用什麼牌子沐浴乳", "化療期間沐浴乳挑選經驗分享" ], "pillars": [ "化療皮膚敏感無香沐浴乳", "乳癌病友沐浴用品挑選", "荷爾蒙治療對香味敏感", "癌症康復後換清潔品牌", "抗敏無香沐浴乳推薦", "化療期間皮膚照護" ], "exclusions": [ "純曬照、純分享日常生活的貼文", "沒有求助、沒有換產品需求的閒聊", "只談癌症治療、確診心情但未提及清潔/沐浴困擾", "推廣其他品牌沐浴乳或業配他牌的貼文", "男性攝護腺癌、肺癌等與香味敏感較無直接相關的癌別(除非明確提到化學敏感)", "寵物洗毛精、洗碗精等其他品類", "已使用 ecostore 或他牌抗敏沐浴乳且滿意、無換品牌需求的貼文", "醫療專業衛教、營養師發文等非病友視角貼文" ], "patrolKeywords": [ "化療 沐浴乳 推薦", "無香沐浴乳 請問", "乳癌 沐浴乳 推薦", "荷爾蒙 敏感 沐浴乳", "化療 皮膚 沐浴乳", "癌症 無香 沐浴乳", "病友 沐浴乳 推薦", "抗敏 沐浴乳 推薦" ] }`) } // BuildResearchMapUserPrompt is kept for tests; production uses analysis + finalize prompts. func BuildResearchMapUserPrompt(in ResearchMapInput) string { return BuildResearchMapFinalizeUserPrompt(in, "") } func ResearchMapRetryUserPrompt() string { return strings.TrimSpace(`上次產出過於簡略或項目不足。請重新產出完整 JSON,密度對齊系統 prompt 優秀範例: - 必須緊扣【輸入資料】中的品牌、產品與主題目標,不可照搬範例用字 - audienceSummary ≥80 字,contentGoal ≥50 字(contentGoal 要寫入完整產品名稱) - questions ≥8、pillars ≥6、exclusions ≥8(exclusions 不可含時間條件) - patrolKeywords 要像真人會在 Threads 搜尋框打的 2~4 詞短句,必須同時包含困擾與品類 - 每句都要具體、可執行,不要用單一關鍵字敷衍`) } func ParseResearchMapOutput(raw string) (ResearchMap, error) { payload, err := extractResearchJSONObject(raw) if err != nil { return ResearchMap{}, err } var out ResearchMap if err := json.Unmarshal(payload, &out); err != nil { return ResearchMap{}, fmt.Errorf("parse research map json: %w", err) } out.AudienceSummary = strings.TrimSpace(out.AudienceSummary) out.ContentGoal = strings.TrimSpace(out.ContentGoal) out.Questions = cleanStringList(out.Questions) out.Pillars = cleanStringList(out.Pillars) out.Exclusions = filterTimeExclusions(cleanStringList(out.Exclusions)) out.PatrolKeywords = cleanStringList(out.PatrolKeywords) if out.AudienceSummary == "" && len(out.Questions) == 0 { return ResearchMap{}, fmt.Errorf("research map missing audience or questions") } return out, nil } func ResearchMapTooThin(m ResearchMap) bool { if utf8.RuneCountInString(m.AudienceSummary) < minAudienceRunes { return true } if utf8.RuneCountInString(strings.TrimSpace(m.ContentGoal)) < minContentGoalRunes { return true } if len(m.Questions) < minResearchQuestions { return true } if len(m.Pillars) < minResearchPillars { return true } if len(m.Exclusions) < minResearchExclusions { return true } return false } var exclusionTimePattern = regexp.MustCompile(`近期|過舊|太久|天內|天前|幾天|一週|上週|發文時間|非近期|多久以前|月份前|昨天|今天發文|時間窗口|理想\s*\d`) func filterTimeExclusions(items []string) []string { if len(items) == 0 { return items } out := make([]string, 0, len(items)) for _, item := range items { if exclusionTimePattern.MatchString(item) { continue } out = append(out, item) } return out } func cleanStringList(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 extractResearchJSONObject(raw string) ([]byte, error) { raw = strings.TrimSpace(raw) if m := researchMapFenceRE.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("research map output missing json object") } return []byte(raw[start : end+1]), nil } func ResearchMapTopicLabel(brandDisplayName, topicName string) string { if topic := strings.TrimSpace(topicName); topic != "" { return topic } return strings.TrimSpace(brandDisplayName) }