309 lines
13 KiB
Go
309 lines
13 KiB
Go
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)
|
||
}
|