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, "") if i < 0 { break } raw = raw[i+7:] j := strings.Index(raw, "") 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, "") t = strings.TrimSpace(t) if utf8.RuneCountInString(t) < 6 { return "" } if utf8.RuneCountInString(t) > 80 { r := []rune(t) t = string(r[:80]) + "…" } return t }