package ownpost import ( "context" "fmt" "regexp" "strings" "unicode/utf8" domai "haixun-backend/internal/model/ai/domain/usecase" ) type ReplyDraftInput struct { AccountName string PersonaBlock string PostText string ReplyText string ReplyToID string ThreadPostText string ParentReplyText string } var ( wrapQuoteRE = regexp.MustCompile(`^["'「『](.*)["'」』]$`) multiBlankRE = regexp.MustCompile(`\n{3,}`) sentenceEndRE = regexp.MustCompile(`([。!?…!?])\s*`) aiPhraseRE = regexp.MustCompile(`(?i)(很高興|感謝您的|希望這對您|作為一個|總而言之|綜上所述|不妨考慮|值得注意)`) ) func isMentionReplyDraft(in ReplyDraftInput) bool { return strings.TrimSpace(in.ThreadPostText) != "" || strings.TrimSpace(in.ParentReplyText) != "" } func BuildReplyDraftPrompt(in ReplyDraftInput) (system string, user string) { if isMentionReplyDraft(in) { return buildMentionReplyDraftPrompt(in) } accountName := strings.TrimSpace(in.AccountName) if accountName == "" { accountName = "我" } postText := strings.TrimSpace(in.PostText) if postText == "" { postText = "(貼文內容未提供)" } replyText := strings.TrimSpace(in.ReplyText) replyToID := strings.TrimSpace(in.ReplyToID) personaBlock := strings.TrimSpace(in.PersonaBlock) if personaBlock == "" { personaBlock = "(尚未設定人設,請用口語、像真人在 Threads 回留言)" } system = strings.TrimSpace(` 你是 Threads 貼文作者本人,要在自己貼文底下回留言或接續串文。 請用台灣繁體中文,像真人用手機打字,完全不像 AI 或客服。 硬規則: 1. 人設語氣是最高優先:用字、節奏、距離感、價值觀必須符合【1. 人設】,不得變成通用網友或官方口吻。 2. 只輸出一則回覆正文:不要標題、不要 JSON、不要用引號包裹、不要解釋你怎麼寫。 3. 先讀【2. 我的貼文】掌握語境;若有【3. 要回覆的留言】,要直接回應對方說的內容,不要自說自話。 4. 禁止 AI 腔:不要「很高興/感謝您的/希望這對您有幫助/作為一個…/總而言之/綜上所述」這類句型。 5. 禁止客服業配腔:不要硬推、不要條列式教學、不要一次講太多重點。 6. 排版要像真人留言:2~4 個短段落,每段 1~2 句;段落之間必須換行(用實際換行,不要整段擠在一起)。 7. 可口語、可省略主詞、可用 …、可 0~1 個 emoji;總長度約 40~180 字,最多不超過 280 字。 8. 你是貼文作者本人在回,不要假裝是粉絲或路人。`) var b strings.Builder b.WriteString("【1. 人設】\n") b.WriteString(personaBlock) b.WriteString("\n\n帳號名稱:") b.WriteString(accountName) b.WriteString("\n\n【2. 我的貼文】\n") b.WriteString(postText) b.WriteString("\n\n【3. 要回覆的留言】\n") if replyToID != "" && replyText != "" { b.WriteString(replyText) b.WriteString("\n\n請依人設語氣,回覆這則留言。") } else { b.WriteString("(無特定留言,這是接續自己貼文的補充回覆/串文)") b.WriteString("\n\n請依人設語氣,寫一則自然接續這篇貼文的留言。") } user = b.String() return system, user } func buildMentionReplyDraftPrompt(in ReplyDraftInput) (system string, user string) { accountName := strings.TrimSpace(in.AccountName) if accountName == "" { accountName = "我" } threadText := strings.TrimSpace(in.ThreadPostText) if threadText == "" { threadText = strings.TrimSpace(in.PostText) } if threadText == "" { threadText = "(主串貼文內容未提供)" } parentText := strings.TrimSpace(in.ParentReplyText) mentionText := strings.TrimSpace(in.ReplyText) if mentionText == "" { mentionText = "(對方 @ 你的內容未提供)" } personaBlock := strings.TrimSpace(in.PersonaBlock) if personaBlock == "" { personaBlock = "(尚未設定人設,請用口語、像真人在 Threads 回留言)" } system = strings.TrimSpace(` 你是 Threads 帳號本人,有人在你參與的串文裡 @ 你,你要回覆對方。 請用台灣繁體中文,像真人用手機打字,完全不像 AI 或客服。 硬規則: 1. 人設語氣是最高優先:用字、節奏、距離感必須符合【1. 人設】。 2. 先讀【2. 主串貼文】理解整串在講什麼;若有【3. 上層留言】,要知道留言脈絡。 3. 你的回覆必須直接回應【4. 對方 @ 你的內容】,不要忽略對方 tag 你的那句話。 4. 只輸出一則回覆正文:不要標題、不要 JSON、不要用引號包裹、不要解釋你怎麼寫。 5. 禁止 AI 腔與客服腔;2~4 個短段落,段落間換行;約 40~180 字,最多 280 字。 6. 你是被 @ 的當事人在回,語氣自然、有來有往。`) var b strings.Builder b.WriteString("【1. 人設】\n") b.WriteString(personaBlock) b.WriteString("\n\n帳號名稱:") b.WriteString(accountName) b.WriteString("\n\n【2. 主串貼文】\n") b.WriteString(threadText) if parentText != "" { b.WriteString("\n\n【3. 上層留言】\n") b.WriteString(parentText) } b.WriteString("\n\n【4. 對方 @ 你的內容】\n") b.WriteString(mentionText) b.WriteString("\n\n請依人設語氣,回覆對方 @ 你的這則內容。") return system, b.String() } func GenerateReplyDraft(ctx context.Context, ai domai.UseCase, req domai.GenerateRequest, in ReplyDraftInput) (string, error) { system, user := BuildReplyDraftPrompt(in) temp := 0.9 req.System = system req.Messages = []domai.Message{{Role: "user", Content: user}} req.Temperature = &temp out, err := ai.GenerateText(ctx, req) if err != nil { return "", err } text := NormalizeHumanReply(out.Text) if text == "" { return "", fmt.Errorf("AI 未回傳回覆草稿") } return text, nil } // NormalizeHumanReply trims AI artifacts and nudges layout toward short mobile-style paragraphs. func NormalizeHumanReply(raw string) string { text := strings.TrimSpace(raw) if text == "" { return "" } if m := wrapQuoteRE.FindStringSubmatch(text); len(m) == 2 { text = strings.TrimSpace(m[1]) } text = strings.ReplaceAll(text, `\n`, "\n") text = multiBlankRE.ReplaceAllString(text, "\n\n") text = strings.TrimSpace(text) if !strings.Contains(text, "\n") { if sentenceCount(text) >= 2 || utf8.RuneCountInString(text) > 72 { text = breakLongReply(text) } } if aiPhraseRE.MatchString(text) && !strings.Contains(text, "\n") { text = breakLongReply(text) } return strings.TrimSpace(text) } func sentenceCount(text string) int { n := 0 for _, r := range text { switch r { case '。', '!', '?', '…', '!', '?': n++ } } return n } func breakLongReply(text string) string { parts := sentenceEndRE.Split(text, -1) seps := sentenceEndRE.FindAllString(text, -1) if len(parts) <= 1 { return text } var chunks []string var current strings.Builder for i, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } sep := "" if i < len(seps) { sep = strings.TrimSpace(seps[i]) } segment := part + sep if current.Len() == 0 { current.WriteString(segment) continue } if utf8.RuneCountInString(current.String())+utf8.RuneCountInString(segment) > 42 { chunks = append(chunks, strings.TrimSpace(current.String())) current.Reset() current.WriteString(segment) continue } current.WriteString(segment) } if current.Len() > 0 { chunks = append(chunks, strings.TrimSpace(current.String())) } if len(chunks) <= 1 { return text } return strings.Join(chunks, "\n") }