377 lines
9.7 KiB
Go
377 lines
9.7 KiB
Go
|
|
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/禁忌。
|
|||
|
|
- 主貼約 80~260 字,可分段空行;回文約 20~100 字。
|
|||
|
|
- 話題僅作靈感,必須用「這個人會怎麼講」重寫。
|
|||
|
|
|
|||
|
|
【話題靈感】(來源:%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
|
|||
|
|
}
|