105 lines
3.2 KiB
Go
105 lines
3.2 KiB
Go
package placement
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"regexp"
|
||
"strings"
|
||
)
|
||
|
||
type ResearchMap struct {
|
||
AudienceSummary string `json:"audienceSummary"`
|
||
ContentGoal string `json:"contentGoal"`
|
||
Questions []string `json:"questions"`
|
||
Pillars []string `json:"pillars"`
|
||
Exclusions []string `json:"exclusions"`
|
||
}
|
||
|
||
type ResearchMapInput struct {
|
||
Label string
|
||
SeedQuery string
|
||
Brief string
|
||
ProductContext string
|
||
}
|
||
|
||
var researchMapFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```")
|
||
|
||
func BuildResearchMapSystemPrompt() string {
|
||
return strings.TrimSpace(`你是 Threads(脆)產品置入研究顧問。目標是幫品牌找到「近期發文、作者有需求、現在留言還來得及自然推薦產品」的貼文。
|
||
|
||
規則:
|
||
1. questions 與 pillars 會直接拿去 Threads 搜尋——每句 5~20 字,像真人求助
|
||
2. questions 至少 5 個;pillars 至少 4 個;exclusions 至少 4 個
|
||
3. contentGoal 要寫:找到近期發文且可自然留言置入的貼文
|
||
4. 全部繁體中文,貼近台灣 Threads
|
||
5. 只回傳一個 JSON:audienceSummary, contentGoal, questions, pillars, exclusions`)
|
||
}
|
||
|
||
func BuildResearchMapUserPrompt(in ResearchMapInput) 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))
|
||
b.WriteString("\n【產品置入】\n")
|
||
product := FormatProductContextForPrompt(in.ProductContext)
|
||
if product == "" {
|
||
product = "(尚未填寫)"
|
||
}
|
||
b.WriteString(product)
|
||
b.WriteString("\n\n請產出研究地圖 JSON。")
|
||
return b.String()
|
||
}
|
||
|
||
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 = cleanStringList(out.Exclusions)
|
||
if out.AudienceSummary == "" && len(out.Questions) == 0 {
|
||
return ResearchMap{}, fmt.Errorf("research map missing audience or questions")
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
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
|
||
}
|