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 } 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 = "匿名" } 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), "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 = trimDraftText(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 }