139 lines
3.7 KiB
Go
139 lines
3.7 KiB
Go
package outreach
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"regexp"
|
||
"strings"
|
||
|
||
libprompt "haixun-backend/internal/library/prompt"
|
||
)
|
||
|
||
const maxChars = 500
|
||
|
||
type Draft struct {
|
||
Text string `json:"text"`
|
||
Angle string `json:"angle"`
|
||
Rationale string `json:"rationale"`
|
||
}
|
||
|
||
type GenerateResult struct {
|
||
Relevance float64 `json:"relevance"`
|
||
Reason string `json:"reason"`
|
||
Drafts []Draft `json:"drafts"`
|
||
}
|
||
|
||
type GenerateInput struct {
|
||
Persona string
|
||
TopicLabel string
|
||
AudienceBrief string
|
||
ProductBrief string
|
||
PlacementReason string
|
||
TargetText string
|
||
AuthorName string
|
||
Count int
|
||
Regenerate bool
|
||
PreviousDraftsHint string
|
||
}
|
||
|
||
var codeFenceRE = regexp.MustCompile(`(?s)^` + "```(?:json)?\\s*(.*?)\\s*" + "```$")
|
||
|
||
func BuildUserPrompt(in GenerateInput) (string, error) {
|
||
count := in.Count
|
||
if count <= 0 {
|
||
count = 2
|
||
}
|
||
personaBlock := ""
|
||
if strings.TrimSpace(in.Persona) != "" {
|
||
personaBlock = "人設與語氣:\n" + strings.TrimSpace(in.Persona) + "\n"
|
||
}
|
||
audienceLine := ""
|
||
if strings.TrimSpace(in.AudienceBrief) != "" {
|
||
audienceLine = "受眾與情境:" + strings.TrimSpace(in.AudienceBrief)
|
||
}
|
||
productBrief := strings.TrimSpace(in.ProductBrief)
|
||
if productBrief == "" {
|
||
productBrief = "(尚未填寫品牌與產品,請先給實用建議,不要捏造品牌)"
|
||
}
|
||
reasonLine := ""
|
||
if strings.TrimSpace(in.PlacementReason) != "" {
|
||
reasonLine = "為何適合留言:" + strings.TrimSpace(in.PlacementReason)
|
||
}
|
||
topic := strings.TrimSpace(in.TopicLabel)
|
||
if topic == "" {
|
||
topic = "未指定"
|
||
}
|
||
author := strings.TrimSpace(in.AuthorName)
|
||
if author == "" {
|
||
author = "匿名"
|
||
}
|
||
regenerateLine := ""
|
||
if in.Regenerate || strings.TrimSpace(in.PreviousDraftsHint) != "" {
|
||
regenerateLine = "\n【重新生成】請產出與舊版明顯不同的新草稿:換開頭、換切角、換句式與情境細節,不要複製舊文案。"
|
||
if hint := strings.TrimSpace(in.PreviousDraftsHint); hint != "" {
|
||
regenerateLine += "\n" + hint
|
||
}
|
||
}
|
||
return libprompt.OutreachPlacementUser(map[string]string{
|
||
"persona_block": personaBlock,
|
||
"topic_label": topic,
|
||
"audience_line": audienceLine,
|
||
"product_brief": productBrief,
|
||
"placement_reason_line": reasonLine,
|
||
"author_name": author,
|
||
"target_text": strings.TrimSpace(in.TargetText),
|
||
"regenerate_line": regenerateLine,
|
||
"count": fmt.Sprintf("%d", count),
|
||
})
|
||
}
|
||
|
||
func ParseGenerateOutput(raw string) (GenerateResult, error) {
|
||
payload, err := extractJSONObject(raw)
|
||
if err != nil {
|
||
return GenerateResult{}, err
|
||
}
|
||
var out GenerateResult
|
||
if err := json.Unmarshal(payload, &out); err != nil {
|
||
return GenerateResult{}, fmt.Errorf("parse outreach json: %w", err)
|
||
}
|
||
if len(out.Drafts) == 0 {
|
||
return GenerateResult{}, fmt.Errorf("outreach drafts missing")
|
||
}
|
||
for i := range out.Drafts {
|
||
out.Drafts[i].Text = NormalizeDraftText(out.Drafts[i].Text)
|
||
if out.Drafts[i].Text == "" {
|
||
return GenerateResult{}, fmt.Errorf("outreach draft %d empty", i+1)
|
||
}
|
||
}
|
||
if out.Relevance < 0 {
|
||
out.Relevance = 0
|
||
}
|
||
if out.Relevance > 1 {
|
||
out.Relevance = 1
|
||
}
|
||
out.Reason = strings.TrimSpace(out.Reason)
|
||
return out, nil
|
||
}
|
||
|
||
func trimDraftText(text string) string {
|
||
text = strings.TrimSpace(text)
|
||
runes := []rune(text)
|
||
if len(runes) > maxChars {
|
||
return string(runes[:maxChars])
|
||
}
|
||
return text
|
||
}
|
||
|
||
func extractJSONObject(raw string) ([]byte, error) {
|
||
raw = strings.TrimSpace(raw)
|
||
if m := codeFenceRE.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("outreach output missing json object")
|
||
}
|
||
return []byte(raw[start : end+1]), nil
|
||
}
|