haixunMaster/haixun-backend/internal/library/placement/research_map.go

309 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 ≥8、pillars ≥6、exclusions ≥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)
}