thread-master/backend/internal/library/placement/research_map.go

309 lines
13 KiB
Go
Raw Permalink Normal View History

2026-06-26 08:37:04 +00:00
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**
## 請依序回答每段 24 繁體中文具體可執行
1. **品牌與產品**這個品牌產品能解決什麼具體困擾關鍵賣點是什麼
2. **受眾輪廓**在什麼生活治療情境會因什麼症狀發文求助
3. **求助句型方向**Threads 上可能出現哪些求助句 610 個方向尚未定稿
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 每組 616 questions 提煉搜尋短句必須可直接拿去搜尋`)
return b.String()
}
func BuildResearchMapSystemPrompt() string {
return strings.TrimSpace(`你是 Threads產品置入研究顧問目標是幫品牌找到近期發文作者有需求現在留言還來得及自然推薦產品的貼文不是找爆款來模仿發文
## 工作流程
- 你會收到輸入資料品牌產品主題目標等前置分析
- 請先理解分析與輸入的對應關係再產出研究地圖 JSON
- 產出必須緊扣本次品牌與產品不可套用與輸入無關的通用內容
## 產出密度重要
- 這份研究地圖要給真人閱讀與執行**寧可詳盡也不要過度精簡**
- 每個欄位都要寫滿規格下限不可用單一關鍵字標籤語或一句話敷衍
- 只回傳一個 JSON 物件不要 markdown 說明
## 核心原則
1. questions pillars 會直接拿去 Threads 找貼文這兩項是最重要的產出
2. 置入時間窗口最重要優先近期求助帖作者有困擾留言區還能自然推薦
3. 海巡要能盡量找到可置入貼文但寧可少給也不要跑題
4. 複合主題不可拆成過寬單字寵物洗毛精 只搜寵物狗狗
5. 全部繁體中文貼近台灣 Threads 使用者口語
## audienceSummary35 至少 80
- 具體描述生活情境正在煩什麼為什麼會發文求助跟產品有什麼關係
- 不要寫注重保養的消費者這類空泛句
## contentGoal23 至少 50
- 明確寫找到近期發文理想 3 天內作者本身有產品可解決的需求留言區還能自然推薦的貼文
- **必須寫入產品置入中的產品名稱**含品類與關鍵賣點抗敏無香沐浴露不可用目標產品等泛稱代替
- 強調現在就能留言置入還來得及且不突兀不是模仿發文或內容企劃
## questions至少 8 每句 824
- 像真人會在 Threads 打的求助句帶治療階段困擾求推薦請益經驗分享
- 要涵蓋不同角度症狀能不能用品牌推薦挑選經驗康復後換品
- 癌症保養沐浴乳這種單一關鍵字
## pillars至少 6 每句 622
- 允許的內容方向用來找貼文並過濾結果要比 questions 更偏主題詞組
- 保養健康這種過寬詞
## exclusions至少 8 每句 828
- 觸及即排除的**貼文類型內容方向**要寫清楚為什麼不能置入純晒照業配無求助跑題癌別錯品類已滿意他牌非病友視角等
- **禁止寫時間相關條件**不要碰不是篩發文時間用的不可出現過舊貼文非近期發文3 天前一週以上發文太久置入時間窗口只寫在 contentGoal
## patrolKeywords68 每組 614
- 這不是分類標籤而是**真人會貼進 Threads 搜尋框的短句**
- 每組 24 個詞空格分隔必須同時保留情境困擾產品品類用途
- 優先格式困擾 品類族群 品類 推薦症狀 品類 請問例如化療 沐浴乳 推薦無香 洗衣精 請問
- 要能找到成果過短過廣太像標籤的不要給癌症沐浴健康生活環境荷爾蒙
- 不要整句複製 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 8pillars 6exclusions 8exclusions 不可含時間條件
- patrolKeywords 要像真人會在 Threads 搜尋框打的 24 詞短句必須同時包含困擾與品類
- 每句都要具體可執行不要用單一關鍵字敷衍`)
}
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)
}