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
|
|||
|
|
}
|