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

226 lines
7.5 KiB
Go
Raw Normal View History

2026-07-01 08:42:51 +00:00
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")
}