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")
|
|||
|
|
}
|