thread-master/apps/backend/internal/module/studio/usecase/persona_preview.go

377 lines
9.7 KiB
Go
Raw Permalink Normal View History

2026-07-13 01:15:30 +00:00
package usecase
import (
"context"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"math/rand"
"net/http"
"strings"
"time"
"unicode/utf8"
"apps/backend/internal/module/studio/domain"
)
// PersonaPreview依指紋一次 LLM 產主貼 + 回文(可抓新聞當話題靈感)。
func (s *Service) PersonaPreview(ctx context.Context, ownerUID int64, personaID, topic string, useNews bool) (*domain.PersonaPreview, error) {
if ownerUID <= 0 {
return nil, domain.ErrForbidden
}
personaID = strings.TrimSpace(personaID)
if personaID == "" {
return nil, fmt.Errorf("%w: empty persona_id", domain.ErrValidation)
}
p, err := s.GetPersona(ctx, ownerUID, personaID)
if err != nil {
return nil, err
}
fp := strings.TrimSpace(p.Style.DraftText)
if fp == "" {
fp = serializeDraftText(p.Style.Draft)
}
if fp == "" && strings.TrimSpace(p.Style.Draft.Tone) == "" {
return nil, fmt.Errorf("%w: 人設尚無指紋,請先完成分析或手動填寫指紋", domain.ErrValidation)
}
if err := s.billAI(ctx, ownerUID, "persona preview", "compose.personaPreview"); err != nil {
return nil, err
}
topic = strings.TrimSpace(topic)
topicSource := "manual"
if topic == "" {
if useNews {
if t, ok := fetchNewsTopic(ctx); ok {
topic = t
topicSource = "news"
}
}
if topic == "" {
topic = pickFallbackTopic(p)
topicSource = "fallback"
}
}
fpBlock := buildFingerprintBlock(p, fp)
prompt := buildPreviewBothPrompt(fpBlock, topic, topicSource)
provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID)
notes := ""
var postText, replyText string
if kerr == nil && strings.TrimSpace(apiKey) != "" && !isSyntheticAIKey(apiKey) {
// 一次呼叫同時產主貼+回文,避免雙請求卡過 gateway 30s
raw, aerr := s.completeLLM(ctx, provider, model, apiKey, prompt)
if aerr != nil {
notes = "產文失敗,已用指紋規則試產:" + truncate(aerr.Error(), 100)
postText = rulePreviewPost(p, topic)
replyText = rulePreviewReply(p, postText)
} else {
postText, replyText = parsePreviewPair(raw)
if postText == "" {
postText = rulePreviewPost(p, topic)
}
if replyText == "" {
replyText = rulePreviewReply(p, postText)
}
if notes == "" && topicSource == "news" {
notes = "話題取自即時新聞,已改寫成人設口吻"
}
}
_ = model
_ = provider
} else {
postText = rulePreviewPost(p, topic)
replyText = rulePreviewReply(p, postText)
notes = "尚未設定 API Key目前用指紋規則試產。請到設定填寫 Key 後再試。"
}
return &domain.PersonaPreview{
Topic: topic,
TopicSource: topicSource,
PostText: postText,
ReplyText: replyText,
Notes: notes,
}, nil
}
func buildFingerprintBlock(p *domain.Persona, fp string) string {
var b strings.Builder
if n := strings.TrimSpace(p.Name); n != "" {
b.WriteString("名稱:")
b.WriteString(n)
b.WriteString("\n")
}
if br := strings.TrimSpace(p.Brief); br != "" {
b.WriteString("定位:")
b.WriteString(br)
b.WriteString("\n")
}
b.WriteString("【語言指紋】\n")
b.WriteString(fp)
if len(p.Style.Dimensions) > 0 {
b.WriteString("\n【8D 摘要】\n")
for _, k := range dimKeys {
if d, ok := p.Style.Dimensions[k]; ok && strings.TrimSpace(d.Summary) != "" {
b.WriteString(k)
b.WriteString("")
b.WriteString(d.Summary)
b.WriteString("\n")
}
}
}
if len(p.Guard.Avoid) > 0 {
b.WriteString("【禁止】")
b.WriteString(strings.Join(p.Guard.Avoid, "、"))
b.WriteString("\n")
}
return b.String()
}
func buildPreviewBothPrompt(fpBlock, topic, topicSource string) string {
srcHint := "自訂話題"
if topicSource == "news" {
srcHint = "今日新聞標題(請轉成個人觀察/情緒/提問,不要硬抄標題、不要寫成新聞稿)"
} else if topicSource == "fallback" {
srcHint = "生活靈感"
}
return strings.TrimSpace(fmt.Sprintf(`
你正在扮演一位 Threads 使用者本人不是分析師不是助手
任務依語言指紋寫一則主貼一則短回文
規則強制
- 只輸出一個 JSON 物件不要 markdown 代碼塊不要前言
- 繁體中文台灣用語口語像真人滑手機打的
- 嚴格遵守指紋的語氣節奏用字CTA禁忌
- 主貼約 80260 可分段空行回文約 20100
- 話題僅作靈感必須用這個人會怎麼講重寫
話題靈感來源%s
%s
%s
輸出格式
{"post":"主貼正文","reply":"回文正文"}
`, srcHint, topic, fpBlock))
}
// parsePreviewPair 解析 {"post","reply"} 或寬鬆切割。
func parsePreviewPair(raw string) (post, reply string) {
s := strings.TrimSpace(raw)
if s == "" {
return "", ""
}
// strip fences
if i := strings.Index(s, "{"); i >= 0 {
if j := strings.LastIndex(s, "}"); j > i {
s = s[i : j+1]
}
}
var parsed struct {
Post string `json:"post"`
Reply string `json:"reply"`
// 別名
PostText string `json:"post_text"`
ReplyText string `json:"reply_text"`
}
if err := json.Unmarshal([]byte(s), &parsed); err == nil {
post = cleanGeneratedText(coalesceStr(parsed.Post, parsed.PostText))
reply = cleanGeneratedText(coalesceStr(parsed.Reply, parsed.ReplyText))
return post, reply
}
// 非 JSON整段當主貼
return cleanGeneratedText(raw), ""
}
func coalesceStr(a, b string) string {
if strings.TrimSpace(a) != "" {
return a
}
return b
}
func cleanGeneratedText(raw string) string {
return cleanGeneratedTextMax(raw, 500)
}
func cleanGeneratedTextMax(raw string, maxRunes int) string {
s := strings.TrimSpace(raw)
if s == "" {
return ""
}
// 去掉常見 markdown 圍欄
if strings.HasPrefix(s, "```") {
s = strings.TrimPrefix(s, "```json")
s = strings.TrimPrefix(s, "```")
s = strings.TrimSpace(s)
if i := strings.LastIndex(s, "```"); i >= 0 {
s = strings.TrimSpace(s[:i])
}
}
s = strings.TrimSpace(s)
for _, p := range []string{"主貼:", "主贴:", "貼文:", "仿寫:", "回覆:", "回文:", "Reply:", "Post:", "Mimic:"} {
s = strings.TrimPrefix(s, p)
s = strings.TrimSpace(s)
}
if maxRunes <= 0 {
maxRunes = 500
}
if utf8.RuneCountInString(s) > maxRunes {
r := []rune(s)
s = string(r[:maxRunes]) + "…"
}
return s
}
func rulePreviewPost(p *domain.Persona, topic string) string {
d := p.Style.Draft
hooks := d.Hooks
if hooks == "" {
hooks = "先丟自己的卡關"
}
cta := d.CtaStyle
if cta == "" {
cta = "你們呢?"
}
ex := d.Examples
lines := []string{
fmt.Sprintf("最近卡在「%s」。", truncate(topic, 40)),
"",
hooks + "——講真的,不是規格文那種。",
}
if ex != "" {
lines = append(lines, truncate(ex, 80))
}
lines = append(lines, "", cta)
return strings.Join(lines, "\n")
}
func rulePreviewReply(p *domain.Persona, postText string) string {
_ = p
return fmt.Sprintf("懂,我自己也遇過類似的。%s——你們後來怎麼解的", truncate(postText, 24))
}
func pickFallbackTopic(p *domain.Persona) string {
seeds := []string{
"最近大家在煩的時間管理",
"網購踩雷又不能退的感覺",
"加班後只想滑手機不想動",
"突然很想整理房間但動不了",
"朋友安利的東西到底能不能信",
"週末計劃很多結果什麼都沒做",
"天氣一變心情就跟著飄",
"想學新東西但資訊量爆炸",
}
if a := strings.TrimSpace(p.Style.Draft.Audience); a != "" {
seeds = append(seeds, "跟「"+truncate(a, 20)+"」有關的日常卡關")
}
return seeds[rand.Intn(len(seeds))]
}
// fetchNewsTopic — Google News 繁中 RSS短超時失敗就走 fallback
func fetchNewsTopic(ctx context.Context) (string, bool) {
urls := []string{
"https://news.google.com/rss?hl=zh-TW&gl=TW&ceid=TW:zh-Hant",
"https://news.google.com/rss/headlines/section/topic/TECHNOLOGY?hl=zh-TW&gl=TW&ceid=TW:zh-Hant",
}
u := urls[rand.Intn(len(urls))]
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", false
}
req.Header.Set("User-Agent", "HarborDesk/1.0 (+persona-preview)")
client := &http.Client{Timeout: 3 * time.Second}
res, err := client.Do(req)
if err != nil {
return "", false
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
return "", false
}
body, err := io.ReadAll(io.LimitReader(res.Body, 512<<10))
if err != nil || len(body) == 0 {
return "", false
}
titles := parseRSSTitles(body)
if len(titles) == 0 {
return "", false
}
t := cleanNewsTitle(titles[rand.Intn(len(titles))])
if t == "" {
return "", false
}
return t, true
}
type rssFeed struct {
XMLName xml.Name `xml:"rss"`
Items []rssItem `xml:"channel>item"`
}
type rssItem struct {
Title string `xml:"title"`
}
func parseRSSTitles(body []byte) []string {
var feed rssFeed
if err := xml.Unmarshal(body, &feed); err == nil {
var out []string
for _, it := range feed.Items {
if t := strings.TrimSpace(it.Title); t != "" {
out = append(out, t)
}
}
if len(out) > 0 {
return out
}
}
raw := string(body)
var out []string
for {
i := strings.Index(raw, "<title>")
if i < 0 {
break
}
raw = raw[i+7:]
j := strings.Index(raw, "</title>")
if j < 0 {
break
}
t := strings.TrimSpace(raw[:j])
raw = raw[j+8:]
if strings.Contains(strings.ToLower(t), "google news") {
continue
}
if t != "" {
out = append(out, t)
}
if len(out) >= 15 {
break
}
}
return out
}
func cleanNewsTitle(t string) string {
t = strings.TrimSpace(t)
if i := strings.LastIndex(t, " - "); i > 8 {
t = strings.TrimSpace(t[:i])
}
t = strings.TrimPrefix(t, "<![CDATA[")
t = strings.TrimSuffix(t, "]]>")
t = strings.TrimSpace(t)
if utf8.RuneCountInString(t) < 6 {
return ""
}
if utf8.RuneCountInString(t) > 80 {
r := []rune(t)
t = string(r[:80]) + "…"
}
return t
}