package formula import ( "context" "fmt" "regexp" "strings" "haixun-backend/internal/library/copyvoice" libllmjson "haixun-backend/internal/library/llmjson" "haixun-backend/internal/library/threadspost" domai "haixun-backend/internal/model/ai/domain/usecase" cfdom "haixun-backend/internal/model/content_formula/domain/usecase" ) var generatedDraftFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```") type GenerateInput struct { Topic string Brief string PersonaBlock string ResearchNotes string Formula cfdom.FormulaSummary } type GeneratedDraft struct { Text string `json:"text"` TopicTag string `json:"topic_tag"` Hook string `json:"hook"` Angle string `json:"angle"` Rationale string `json:"rationale"` StructureNotes string `json:"structure_notes"` } func BuildGenerateSystemPrompt() string { return strings.TrimSpace(`你是 Threads / Instagram 貼文代筆。寫出符合人設語言指紋、可直接發布的完整新貼文,不是解釋公式,也不是填空模板。 ` + copyvoice.SystemRules() + ` 規則: - 用台灣繁體中文,符合人設語氣。 - 正文素材以【主題】、【補充】、【周邊知識參考】為準;人設不得改變題材。 - 人設定位決定誰在說,語言指紋決定怎麼說;必須遵守段落節奏、標點換行、知識轉譯與 CTA 習慣。 - text 必須是一篇完整自然的 Threads 貼文,可直接複製發布。 - 不可以出現「公式、模板、Hook、情境、轉折、觀點、收尾、[括號]、步驟、結構」等後台分析字眼。 - 不可以輸出填空骨架,不可以照抄參考原文。 - 有參考原文時:只學資訊密度、情緒力度與可用結構;最後仍要換成人設會講出口的話。 - text 排版:像真人手機發文;可使用自然標點、空行、列點與少量 emoji。不要完全無標點,也不要每句硬換行,除非人設樣本就是這樣。 - topic_tag 請產生 1 個適合 Threads 主題標籤的短詞,不含 #,例如「AI工具」「內容創作」「職場」。 - 只回傳 JSON:text, topic_tag, hook, angle, rationale, structure_notes(繁體中文)。`) } func BuildGenerateUserPrompt(in GenerateInput) string { var b strings.Builder b.WriteString("【主題】\n") b.WriteString(strings.TrimSpace(in.Topic)) if brief := strings.TrimSpace(in.Brief); brief != "" { b.WriteString("\n補充:") b.WriteString(brief) } b.WriteString("\n\n【人設定位、語言指紋與禁忌(最高優先)】\n") b.WriteString(strings.TrimSpace(in.PersonaBlock)) b.WriteString("\n") b.WriteString(copyvoice.PersonaCalibrationNote()) b.WriteString("\n\n【這篇爆文的故事感(只感受情緒與敘事,禁止照搬句型或填空)】\n") b.WriteString(strings.TrimSpace(in.Formula.Formula)) if structure := strings.TrimSpace(in.Formula.Structure); structure != "" { b.WriteString("\n\n敘事推進參考(勿照搬句型):") b.WriteString(structure) } if len(in.Formula.Avoid) > 0 { b.WriteString("\n\n【避免】\n") b.WriteString(strings.Join(in.Formula.Avoid, "\n")) } if notes := strings.TrimSpace(in.ResearchNotes); notes != "" { b.WriteString("\n\n【周邊知識參考】\n") b.WriteString(notes) } if src := strings.TrimSpace(in.Formula.SourcePostText); src != "" { if note := threadspost.MimicLengthGuidance(src); note != "" { b.WriteString("\n\n") b.WriteString(note) } b.WriteString("\n\n【參考原文(僅學結構與篇幅,勿抄內容)】\n") b.WriteString(src) } b.WriteString("\n\n本次敘事切入:") b.WriteString(copyvoice.NarrativeLens(in.Topic)) b.WriteString("\n\n請產出一篇可直接發布的 Threads 貼文 JSON。text 欄位只能放貼文正文,不要放分析、標題、公式、模板或填空欄位。topic_tag 請只放標籤文字,不要加 #。") return b.String() } func GenerateDraft(ctx context.Context, ai domai.UseCase, req domai.GenerateRequest, in GenerateInput) (*GeneratedDraft, error) { temp := 0.82 req.System = BuildGenerateSystemPrompt() req.Messages = []domai.Message{{Role: "user", Content: BuildGenerateUserPrompt(in)}} req.Temperature = &temp out, err := ai.GenerateText(ctx, req) if err != nil { return nil, err } return ParseGeneratedDraft(out.Text) } func extractGeneratedDraftJSON(raw string) ([]byte, error) { raw = strings.TrimSpace(raw) if m := generatedDraftFenceRE.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("generate output missing json object") } return []byte(raw[start : end+1]), nil } func ParseGeneratedDraft(raw string) (*GeneratedDraft, error) { payload, err := extractGeneratedDraftJSON(raw) if err != nil { return nil, err } var draft GeneratedDraft if err := libllmjson.Unmarshal(payload, &draft); err != nil { return nil, fmt.Errorf("parse generated draft: %w", err) } draft.Text = threadspost.FormatDraftText(draft.Text) draft.TopicTag = normalizeTopicTag(draft.TopicTag) if draft.Text == "" { return nil, fmt.Errorf("generated draft missing text") } badTokens := []string{"[", "]", "【", "】", "Hook", "公式", "模板", "情境→", "轉折→", "觀點→", "收尾", "步驟"} for _, token := range badTokens { if strings.Contains(draft.Text, token) { return nil, fmt.Errorf("generated draft looks like a template, not a publishable post") } } return &draft, nil } func normalizeTopicTag(value string) string { value = strings.TrimSpace(strings.TrimPrefix(value, "#")) if len([]rune(value)) > 40 { runes := []rune(value) value = string(runes[:40]) } return value }