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

377 lines
9.7 KiB
Go
Raw 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 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
}