thread-master/backend/internal/library/ownpost/reply_draft.go

227 lines
7.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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. 排版要像真人留言24 個短段落,每段 12 句;段落之間必須換行(用實際換行,不要整段擠在一起)。
7. 可口語、可省略主詞、可用 …、可 01 個 emoji總長度約 40180 字,最多不超過 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 腔與客服腔24 個短段落,段落間換行;約 40180 字,最多 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")
}