227 lines
7.5 KiB
Go
227 lines
7.5 KiB
Go
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")
|
||
}
|