thread-master/apps/backend/internal/module/inspire/usecase/service.go

1479 lines
44 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"unicode/utf8"
"apps/backend/internal/module/ai"
"apps/backend/internal/module/inspire/domain"
"apps/backend/internal/module/search"
usageDomain "apps/backend/internal/module/usage/domain"
usageUC "apps/backend/internal/module/usage/usecase"
"github.com/google/uuid"
)
type Service struct {
Repo domain.Repository
Usage *usageUC.Service
AI ai.Client // tests / fallback
// AIRegistry real xai / opencode-go
AIRegistry *ai.Registry
// ResolveAI returns provider, model, apiKey會員設定
ResolveAI func(ctx context.Context, uid int64) (provider, model, apiKey string, err error)
Search search.Client
// ResolveKey returns api key for meter (optional; tests can leave nil and skip external)
ResolveKey func(ctx context.Context, uid int64, meter string) (mode, apiKey string, err error)
// Persona / 品牌產品 — 真資料注入 prompt可空空則略過該段
Personas PersonaSource
Brands BrandCatalogSource
}
func New(repo domain.Repository) *Service {
return &Service{Repo: repo}
}
// ListTrends — 只讀快取/種子,不網搜、不扣點。找靈感請走 RefreshTrends手動
func (s *Service) ListTrends(ctx context.Context, ownerUID int64) ([]*domain.TrendItem, error) {
list, err := s.Repo.ListTrends(ctx, ownerUID)
if err != nil {
return nil, err
}
if len(list) == 0 {
// 空庫給示意種子,標 seed不打外網
list = defaultTrends(ownerUID)
_ = s.Repo.SaveTrends(ctx, ownerUID, list)
}
return list, nil
}
// RefreshTrends — 手動「找靈感」:先扣 web_search 點數,再網搜抽話題。
// 扣點失敗直接回錯(不 soft ignore搜尋失敗可回種子但不退點已消耗配額嘗試
func (s *Service) RefreshTrends(ctx context.Context, ownerUID int64) ([]*domain.TrendItem, error) {
if err := s.bill(ctx, ownerUID, usageDomain.MeterWebSearch, "找靈感話題", "inspire.refreshTrends"); err != nil {
return nil, err
}
list := s.tryLiveTrends(ctx, ownerUID)
if len(list) == 0 {
list = defaultTrends(ownerUID)
for i := range list {
list[i].Heat = i + 1
list[i].ObservedAt = domain.NowNano()
list[i].ID = "tr_" + uuid.NewString()[:10]
list[i].SourceLabel = "seed"
list[i].Summary = "網搜未取到可用話題,以下為示意"
}
}
if err := s.Repo.SaveTrends(ctx, ownerUID, list); err != nil {
return nil, err
}
return list, nil
}
// tryLiveTrends網搜「關於 Threads 本週在聊什麼」的報導/彙整,再抽出短話題名。
// 注意:沒有官方 Threads 熱搜 API這是話題靈感不是平台即時榜。
func (s *Service) tryLiveTrends(ctx context.Context, ownerUID int64) []*domain.TrendItem {
if s.Search == nil {
return nil
}
key := ""
if s.ResolveKey != nil {
if _, k, err := s.ResolveKey(ctx, ownerUID, usageDomain.MeterWebSearch); err == nil {
key = strings.TrimSpace(k)
}
}
if key == "" || strings.HasPrefix(key, "fake") {
return nil
}
// 計費在 RefreshTrends 已做;此處只搜尋
queries := []string{
"本週 Threads 紅什麼 台灣",
"Threads 熱議話題 列表 台灣 本週",
"Threads 夯 話題 二砂糖 OR 泡麵 OR 颱風 台灣",
}
seen := map[string]struct{}{}
now := domain.NowNano()
out := make([]*domain.TrendItem, 0, 12)
for _, q := range queries {
hits, err := s.Search.Search(ctx, key, q, 10)
if err != nil || len(hits) == 0 {
continue
}
for _, h := range hits {
if isMetaThreadsArticle(h) {
// 仍可從內文抽「紅什麼」段落,但略過整篇當 label
}
for _, label := range extractTopicLabels(h) {
keyNorm := strings.ToLower(strings.TrimSpace(label))
if keyNorm == "" {
continue
}
if _, ok := seen[keyNorm]; ok {
continue
}
if isNoiseTopicLabel(label) {
continue
}
seen[keyNorm] = struct{}{}
summary := strings.TrimSpace(h.Snippet)
if summary == "" {
summary = strings.TrimSpace(h.Title)
}
rank := len(out) + 1
out = append(out, &domain.TrendItem{
ID: "tr_" + uuid.NewString()[:10],
OwnerUID: ownerUID,
Kind: "threads_tag",
Label: label,
Summary: truncate(summary, 120),
Heat: rank, // 列表序位,前端當 #rank 用
Keywords: []string{label},
Samples: []string{truncate(summary, 80)},
SourceLabel: "web",
ObservedAt: now,
})
if len(out) >= 12 {
return out
}
}
}
if len(out) >= 8 {
break
}
}
return out
}
// isMetaThreadsArticle — 談「Threads 功能/趨勢榜上線」的新聞,不是島民在聊的話題本身。
func isMetaThreadsArticle(h search.Hit) bool {
blob := strings.ToLower(h.Title + " " + h.Snippet)
meta := []string{
"趨勢話題", "trending topics", "新功能上線", "功能開放", "功能測試",
"setn", "三立", "必看", "選題sop", "qsearch", "趨勢功能",
}
for _, m := range meta {
if strings.Contains(blob, m) {
return true
}
}
return false
}
func isNoiseTopicLabel(label string) bool {
l := strings.TrimSpace(label)
if l == "" {
return true
}
n := len([]rune(l))
if n < 2 || n > 18 {
return true
}
low := strings.ToLower(l)
// 整段疑問句/感嘆句當 chip 很假
if strings.HasSuffix(l, "") || strings.HasSuffix(l, "?") ||
strings.HasSuffix(l, "") || strings.HasSuffix(l, "!") {
if n > 8 {
return true
}
}
if strings.HasPrefix(l, "為何") || strings.HasPrefix(l, "為什麼") ||
strings.HasPrefix(l, "讓你") || strings.HasPrefix(l, "你還") {
return true
}
noise := []string{
"threads", "meta", "meta.ai", "@meta.ai", "趨勢", "熱門話題", "功能", "上線", "測試", "報導",
"記者", "新聞", "sop", "qsearch", "台灣", "本週", "一週", "脆報",
"必看", "整理", "秒懂", "全文", "gq", "setn", "合法違規",
}
for _, w := range noise {
if low == w || low == "#"+w || strings.Contains(low, "meta.ai") {
return true
}
}
// 整句新聞標題残留
if strings.ContainsAny(l, "|/") || strings.Contains(l, "http") {
return true
}
// 含書名號整句
if strings.Contains(l, "「") || strings.Contains(l, "」") {
return true
}
return false
}
// extractTopicLabels 從搜尋結果抽出短話題hashtag、「」、清理後的短語
func extractTopicLabels(h search.Hit) []string {
title := strings.TrimSpace(h.Title)
snippet := strings.TrimSpace(h.Snippet)
blob := title + "\n" + snippet
var out []string
seen := map[string]struct{}{}
add := func(s string) {
s = strings.TrimSpace(s)
s = strings.Trim(s, "「」『』\"' # ")
s = strings.TrimSpace(s)
if s == "" || isNoiseTopicLabel(s) {
return
}
// 統一短標
s = truncate(s, 16)
k := strings.ToLower(s)
if _, ok := seen[k]; ok {
return
}
seen[k] = struct{}{}
out = append(out, s)
}
// 1) #hashtag
for _, src := range []string{title, snippet} {
for i := 0; i < len(src); i++ {
if src[i] != '#' {
continue
}
rest := src[i:]
end := len(rest)
for j, r := range rest {
if j == 0 {
continue
}
if r == ' ' || r == '\n' || r == '\t' || r == '' || r == '。' || r == ',' || r == '.' || r == '' || r == '|' {
end = j
break
}
}
add(rest[:end])
}
}
// 2) 「…」/『…』
extractQuoted(blob, '「', '」', add)
extractQuoted(blob, '『', '』', add)
// 3) 標題裡「紅什麼/夯什麼」冒號後的片段
for _, sep := range []string{"紅什麼:", "紅什麼:", "夯什麼:", "夯什麼:", "熱議:", "熱議:"} {
if i := strings.Index(title, sep); i >= 0 {
rest := title[i+len(sep):]
// 用?!。切短
for _, cut := range []string{"", "?", "", "!", "。", "", "|"} {
if j := strings.Index(rest, cut); j > 0 {
rest = rest[:j]
break
}
}
// 再拆「、」
for _, part := range splitTopicParts(rest) {
add(part)
}
}
}
// 4) 非 meta 文:用清理後的短標題當一個話題
if !isMetaThreadsArticle(h) {
clean := cleanNewsTitle(title)
if clean != "" && len([]rune(clean)) <= 16 {
add(clean)
} else if clean != "" {
// 取前段關鍵
for _, part := range splitTopicParts(clean) {
add(part)
if len(out) >= 4 {
break
}
}
}
}
return out
}
func extractQuoted(s string, open, close rune, add func(string)) {
runes := []rune(s)
for i := 0; i < len(runes); i++ {
if runes[i] != open {
continue
}
for j := i + 1; j < len(runes); j++ {
if runes[j] == close {
add(string(runes[i+1 : j]))
i = j
break
}
}
}
}
func splitTopicParts(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
// 統一分隔
for _, sep := range []string{"、", "", "/", "·", "•", "", ",", "跟", "與", "和"} {
s = strings.ReplaceAll(s, sep, "|")
}
var parts []string
for _, p := range strings.Split(s, "|") {
p = strings.TrimSpace(p)
if p != "" {
parts = append(parts, p)
}
}
return parts
}
func cleanNewsTitle(title string) string {
t := strings.TrimSpace(title)
// 去【】前綴
for {
if !strings.HasPrefix(t, "【") {
break
}
if i := strings.Index(t, "】"); i >= 0 {
t = strings.TrimSpace(t[i+len("】"):])
continue
}
break
}
// 去站名
for _, sep := range []string{"", "|", " - ", " — "} {
if i := strings.Index(t, sep); i > 0 {
t = strings.TrimSpace(t[:i])
}
}
t = strings.TrimSpace(t)
return t
}
func (s *Service) ListElements(ctx context.Context, ownerUID int64) ([]*domain.Element, error) {
return s.Repo.ListElements(ctx, ownerUID)
}
func (s *Service) SaveElement(ctx context.Context, ownerUID int64, e *domain.Element) (*domain.Element, error) {
if strings.TrimSpace(e.Title) == "" && strings.TrimSpace(e.Body) == "" {
return nil, fmt.Errorf("%w: empty element", domain.ErrValidation)
}
now := domain.NowNano()
if e.ID == "" {
e.ID = "el_" + uuid.NewString()[:10]
e.CreatedAt = now
} else {
ex, err := s.Repo.GetElement(ctx, e.ID)
if err == nil {
if ex.OwnerUID != ownerUID {
return nil, domain.ErrForbidden
}
e.CreatedAt = ex.CreatedAt
} else if err != domain.ErrNotFound {
return nil, err
} else {
e.CreatedAt = now
}
}
e.OwnerUID = ownerUID
e.UpdatedAt = now
if e.Kind == "" {
e.Kind = "snippet"
}
if err := s.Repo.SaveElement(ctx, e); err != nil {
return nil, err
}
return e, nil
}
func (s *Service) RemoveElement(ctx context.Context, ownerUID int64, id string) error {
e, err := s.Repo.GetElement(ctx, id)
if err != nil {
return err
}
if e.OwnerUID != ownerUID {
return domain.ErrForbidden
}
return s.Repo.DeleteElement(ctx, id)
}
func (s *Service) GetSession(ctx context.Context, ownerUID int64) (*domain.Session, error) {
sess, err := s.Repo.GetSession(ctx, ownerUID)
if err == domain.ErrNotFound {
return s.CreateSession(ctx, ownerUID, "")
}
return sess, err
}
func (s *Service) GetSessionByID(ctx context.Context, ownerUID int64, id string) (*domain.Session, error) {
id = strings.TrimSpace(id)
if id == "" {
return s.GetSession(ctx, ownerUID)
}
sess, err := s.Repo.GetSessionByID(ctx, ownerUID, id)
if err != nil {
return nil, err
}
return sess, nil
}
// ListSessionSummaries — 列表(最新在前;標記 active
func (s *Service) ListSessionSummaries(ctx context.Context, ownerUID int64) ([]domain.SessionSummary, error) {
list, err := s.Repo.ListSessions(ctx, ownerUID)
if err != nil {
return nil, err
}
activeID, _ := s.Repo.GetActiveSessionID(ctx, ownerUID)
if activeID == "" && len(list) > 0 {
activeID = list[0].ID
}
out := make([]domain.SessionSummary, 0, len(list))
for _, sess := range list {
out = append(out, sessionToSummary(sess, sess.ID == activeID))
}
return out, nil
}
func sessionToSummary(sess *domain.Session, active bool) domain.SessionSummary {
preview := ""
if n := len(sess.Messages); n > 0 {
preview = truncate(sess.Messages[n-1].Text, 48)
}
title := strings.TrimSpace(sess.Title)
if title == "" {
// 推首則 user 文
for _, m := range sess.Messages {
if m.Role == "user" && strings.TrimSpace(m.Text) != "" {
title = truncate(m.Text, 24)
break
}
}
}
if title == "" {
title = "新對話"
}
return domain.SessionSummary{
ID: sess.ID,
Title: title,
Preview: preview,
MessageCount: len(sess.Messages),
UpdatedAt: sess.UpdatedAt,
CreatedAt: sess.CreatedAt,
Active: active,
}
}
// CreateSession — 開新對話並設為 active舊的保留
func (s *Service) CreateSession(ctx context.Context, ownerUID int64, title string) (*domain.Session, error) {
sess := emptySession(ownerUID)
sess.Title = strings.TrimSpace(title)
if err := s.Repo.SaveSession(ctx, sess); err != nil {
return nil, err
}
if err := s.Repo.SetActiveSessionID(ctx, ownerUID, sess.ID); err != nil {
return nil, err
}
return sess, nil
}
// ActivateSession — 切換目前作用中的對話。
func (s *Service) ActivateSession(ctx context.Context, ownerUID int64, id string) (*domain.Session, error) {
sess, err := s.Repo.GetSessionByID(ctx, ownerUID, strings.TrimSpace(id))
if err != nil {
return nil, err
}
sess.UpdatedAt = domain.NowNano()
if err := s.Repo.SaveSession(ctx, sess); err != nil {
return nil, err
}
if err := s.Repo.SetActiveSessionID(ctx, ownerUID, sess.ID); err != nil {
return nil, err
}
return sess, nil
}
// DeleteSession — 刪除指定對話;若刪的是 active自動切最近或建新。
func (s *Service) DeleteSession(ctx context.Context, ownerUID int64, id string) (*domain.Session, error) {
id = strings.TrimSpace(id)
if id == "" {
return nil, fmt.Errorf("%w: empty session id", domain.ErrValidation)
}
if err := s.Repo.DeleteSessionByID(ctx, ownerUID, id); err != nil {
return nil, err
}
activeID, _ := s.Repo.GetActiveSessionID(ctx, ownerUID)
if activeID == id || activeID == "" {
// 切到剩餘最近一則,或建新
list, err := s.Repo.ListSessions(ctx, ownerUID)
if err != nil {
return nil, err
}
if len(list) == 0 {
return s.CreateSession(ctx, ownerUID, "")
}
return s.ActivateSession(ctx, ownerUID, list[0].ID)
}
return s.GetSession(ctx, ownerUID)
}
func (s *Service) SaveSession(ctx context.Context, ownerUID int64, sess *domain.Session) (*domain.Session, error) {
sess.OwnerUID = ownerUID
sess.UpdatedAt = domain.NowNano()
if sess.ID == "" {
sess.ID = "sess_" + uuid.NewString()[:8]
}
if sess.CreatedAt == 0 {
sess.CreatedAt = sess.UpdatedAt
}
if err := s.Repo.SaveSession(ctx, sess); err != nil {
return nil, err
}
return sess, nil
}
// ClearSession — 相容舊 API開新對話並切過去舊 session 保留,可從列表選回)。
func (s *Service) ClearSession(ctx context.Context, ownerUID int64) (*domain.Session, error) {
return s.CreateSession(ctx, ownerUID, "")
}
// PromptBlock is one labeled chunk of the assembled prompt (for readable preview).
type PromptBlock struct {
Title string
Body string
}
// PromptPreviewResult is the real payload that would be sent to the LLM (no call, no bill).
type PromptPreviewResult struct {
Prompt string
Mode string
Sections []string
Blocks []PromptBlock
Fingerprint string // sha256 hex[:16] of Prompt — same algo as chat done
CharCount int
RuneCount int
Pinned []PromptPinned
Note string
}
type PromptPinned struct {
ID string
Kind string
Title string
Body string
}
// ChatOutcome — session + the exact prompt that was sent to the LLM.
type ChatOutcome struct {
Session *domain.Session
Prompt string
Fingerprint string
Sections []string
Blocks []PromptBlock
CharCount int
RuneCount int
}
func promptFingerprint(prompt string) string {
sum := sha256.Sum256([]byte(prompt))
return hex.EncodeToString(sum[:])[:16]
}
func promptStats(prompt string) (chars, runes int, fp string) {
return len(prompt), utf8.RuneCountInString(prompt), promptFingerprint(prompt)
}
// PreviewPrompt builds the exact same prompt as Chat/ChatStream would, without billing or AI.
func (s *Service) PreviewPrompt(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string) (*PromptPreviewResult, error) {
message = strings.TrimSpace(message)
material = strings.TrimSpace(material)
if mode != "chat" && mode != "generate" {
mode = "chat"
}
sess, err := s.resolveSession(ctx, ownerUID, sessionID)
if err != nil {
return nil, err
}
// Mirror chatInternal: append this-turn user message into a copy before build.
userLog := message
if mode == "generate" {
userLog = generateUserLogMessage(message, material)
}
previewSess := *sess
previewSess.Messages = append(append([]domain.ChatMessage{}, sess.Messages...), domain.ChatMessage{
ID: "preview", Role: "user", Text: userLog, CreatedAt: domain.NowNano(),
})
previewSess.PinnedElementIDs = pinnedIDs
// 預覽不寫回 DB但用同一套摘要規則
maybeRefreshSessionSummary(&previewSess)
var pinned []PromptPinned
for _, id := range pinnedIDs {
if e, eerr := s.Repo.GetElement(ctx, id); eerr == nil && e != nil && e.OwnerUID == ownerUID {
pinned = append(pinned, PromptPinned{
ID: e.ID, Kind: e.Kind, Title: e.Title, Body: e.Body,
})
}
}
persona := s.resolvePersonaSnap(ctx, ownerUID, personaID)
lang := ai.ResponseLanguageFrom(ctx)
if pl := inferPersonaLanguage(persona); pl != "" {
lang = pl
}
prompt, blocks, sections := s.buildInspirePrompt(ctx, ownerUID, pinnedIDs, message, mode, personaID, material, &previewSess, persona, lang)
chars, runes, fp := promptStats(prompt)
out := &PromptPreviewResult{
Prompt: prompt,
Mode: mode,
Sections: sections,
Blocks: blocks,
Fingerprint: fp,
CharCount: chars,
RuneCount: runes,
Pinned: pinned,
Note: "此為送出前實際組裝的完整 prompt與 ChatChatStream 同一 buildInspirePrompt。指紋fingerprint與字數可用來對照送出後回傳值未呼叫 AI、未扣額度。",
}
if mode == "generate" && material == "" {
out.Note += " 產文缺少【待改寫內容】;真送出會被拒絕。"
}
if mode == "chat" && message == "" {
out.Note += " 訊息為空;真送出會被拒絕。"
}
return out, nil
}
func (s *Service) Chat(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string) (*ChatOutcome, error) {
return s.chatInternal(ctx, ownerUID, message, pinnedIDs, mode, personaID, sessionID, material, nil)
}
// ChatStream — 真 AI streamonDelta 每收到一段正文就回呼(可 SSE。結束後 session 已寫入。
func (s *Service) ChatStream(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string, onDelta func(chunk string) error) (*ChatOutcome, error) {
return s.chatInternal(ctx, ownerUID, message, pinnedIDs, mode, personaID, sessionID, material, onDelta)
}
func (s *Service) resolveSession(ctx context.Context, ownerUID int64, sessionID string) (*domain.Session, error) {
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return s.GetSession(ctx, ownerUID)
}
return s.GetSessionByID(ctx, ownerUID, sessionID)
}
// generateUserLogMessage — 寫進 session 的短紀錄,不把整包素材塞進對話泡泡。
func generateUserLogMessage(notes, material string) string {
notes = strings.TrimSpace(notes)
if notes == "" {
notes = "用人設寫成貼文"
}
mat := strings.TrimSpace(material)
preview := truncate(mat, 48)
if preview == "" {
return "【產文】" + notes
}
return "【產文】" + notes + "|素材:" + preview
}
func (s *Service) chatInternal(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string, onDelta func(chunk string) error) (*ChatOutcome, error) {
message = strings.TrimSpace(message)
material = strings.TrimSpace(material)
if mode != "chat" && mode != "generate" {
mode = "chat"
}
if mode == "generate" {
if material == "" {
return nil, fmt.Errorf("%w: 產文需要先鎖定「待改寫內容」", domain.ErrValidation)
}
if message == "" {
message = "用人設寫成 Threads 正文"
}
} else if message == "" {
return nil, fmt.Errorf("%w: empty message", domain.ErrValidation)
}
label := "inspire chat"
if mode == "generate" {
label = "inspire rewrite"
}
if err := s.bill(ctx, ownerUID, usageDomain.MeterAICopy, label, "inspire.chat"); err != nil {
return nil, err
}
sess, err := s.resolveSession(ctx, ownerUID, sessionID)
if err != nil {
return nil, err
}
// 回覆語言:人設指紋/範例優先,其次會員 UI 語系ctx 已由 Auth 注入)
persona := s.resolvePersonaSnap(ctx, ownerUID, personaID)
lang := ai.ResponseLanguageFrom(ctx)
if pl := inferPersonaLanguage(persona); pl != "" {
lang = pl
}
ctx = ai.WithResponseLanguage(ctx, lang)
if mode == "generate" {
if message == "用人設寫成 Threads 正文" || message == "" {
if lang == "en" {
message = "Rewrite as a Threads post in this persona's voice"
} else {
message = "用人設寫成 Threads 正文"
}
}
}
now := domain.NowNano()
sess.PinnedElementIDs = pinnedIDs
userLog := message
if mode == "generate" {
userLog = generateUserLogMessage(message, material)
}
// 自動標題:首則有意義 user 訊息
if strings.TrimSpace(sess.Title) == "" {
if mode == "generate" {
sess.Title = truncate(material, 24)
} else {
sess.Title = truncate(message, 24)
}
}
sess.Messages = append(sess.Messages, domain.ChatMessage{
ID: "msg_" + uuid.NewString()[:8], Role: "user", Text: userLog, CreatedAt: now,
})
// 舊對話摺進摘要(每 N 則才重算prompt 只帶摘要 + 最近幾則
maybeRefreshSessionSummary(sess)
prompt, blocks, sections := s.buildInspirePrompt(ctx, ownerUID, pinnedIDs, message, mode, personaID, material, sess, persona, lang)
chars, runes, fp := promptStats(prompt)
// 靈感:小輸出預算 + 精簡 prompt → 首字與完成都更快
llmCtx := ai.WithMaxTokens(ctx, inspireMaxTokens(mode))
llmCtx = ai.WithTemperature(llmCtx, 0.85)
reply, aerr := s.runInspireLLM(llmCtx, ownerUID, prompt, onDelta)
if aerr != nil {
return nil, aerr
}
if reply == "" {
return nil, fmt.Errorf("%w: AI 回傳空白,請換模型後重試", domain.ErrValidation)
}
asst := domain.ChatMessage{
ID: "msg_" + uuid.NewString()[:8], Role: "assistant", Text: reply, CreatedAt: domain.NowNano(),
}
if mode == "generate" {
body := strings.TrimSpace(reply)
if i := strings.Index(body, "] "); i >= 0 && strings.HasPrefix(body, "[") {
body = strings.TrimSpace(body[i+2:])
}
if body == "" {
body = material
}
asst.Draft = &domain.ChatDraft{Title: truncate(material, 24), Body: body}
if lang == "en" {
asst.Text = "Draft rewritten in persona voice — ready to use."
} else {
asst.Text = "已依人設改寫成草稿,可直接用。"
}
}
sess.Messages = append(sess.Messages, asst)
// 助手回完後再檢查是否該摺摘要(不另打 AI
maybeRefreshSessionSummary(sess)
sess.UpdatedAt = domain.NowNano()
if err := s.Repo.SaveSession(ctx, sess); err != nil {
return nil, err
}
// 聊過的 session 設為 active
_ = s.Repo.SetActiveSessionID(ctx, ownerUID, sess.ID)
return &ChatOutcome{
Session: sess, Prompt: prompt, Fingerprint: fp,
Sections: sections, Blocks: blocks, CharCount: chars, RuneCount: runes,
}, nil
}
func inspireMaxTokens(mode string) int {
// 聊天發想:給足空間讓回應完整;產文才收斂字數/預算。
if mode == "generate" {
return 3072
}
return 2048
}
// 對話視窗:只帶最近幾則原文;更舊的摺進 ContextSummary
const (
inspireKeepRecentChat = 5 // 發想要有足夠上下文
inspireKeepRecentGenerate = 4
inspireSummarizeEvery = 6
inspireSummaryMaxRunes = 480
inspireHistoryLineChat = 120
inspireHistoryLineGen = 90
inspirePinBodyChat = 280
inspirePinBodyGenerate = 280
inspirePromptCapChat = 3200
inspirePromptCapGenerate = 3600
)
// maybeRefreshSessionSummary 把「窗口外」舊訊息摺成摘要。
// 不做額外 LLM 呼叫(免費、快);清空 session 時摘要一併清掉。
func maybeRefreshSessionSummary(sess *domain.Session) {
if sess == nil {
return
}
n := len(sess.Messages)
keep := inspireKeepRecentChat
if n <= keep {
return
}
olderEnd := n - keep
if olderEnd <= 0 {
return
}
// 已覆蓋到 olderEnd 前綴 → 不必重算
if sess.SummaryMsgCount >= olderEnd && strings.TrimSpace(sess.ContextSummary) != "" {
return
}
// 距上次摘要未滿 N 則新舊差 → 暫不重算(沿用舊摘要 + 視窗)
if sess.SummaryMsgCount > 0 && (olderEnd-sess.SummaryMsgCount) < inspireSummarizeEvery {
return
}
older := sess.Messages[:olderEnd]
sess.ContextSummary = compactHistorySummary(older, inspireSummaryMaxRunes)
sess.SummaryMsgCount = olderEnd
}
func compactHistorySummary(msgs []domain.ChatMessage, maxRunes int) string {
if len(msgs) == 0 {
return ""
}
var parts []string
// 從舊到新取重點;過長則只留頭尾
for _, m := range msgs {
role := "使用者"
if m.Role == "assistant" {
role = "助手"
}
t := strings.TrimSpace(m.Text)
if t == "" && m.Draft != nil {
t = strings.TrimSpace(m.Draft.Body)
}
if t == "" {
continue
}
parts = append(parts, role+""+truncate(t, 60))
}
if len(parts) == 0 {
return ""
}
// 太長:保留前 3 + 後 3
if len(parts) > 8 {
head := parts[:3]
tail := parts[len(parts)-3:]
parts = append(head, append([]string{"…"}, tail...)...)
}
return truncate(strings.Join(parts, " / "), maxRunes)
}
// ComposeDraftMaterial — 從 session 規則組裝「待改寫內容」(免費、不呼叫 AI
func ComposeDraftMaterial(sess *domain.Session, extra string) string {
var parts []string
if sess != nil {
if t := strings.TrimSpace(sess.Title); t != "" && t != "新對話" {
parts = append(parts, "主題:"+t)
}
if sum := strings.TrimSpace(sess.ContextSummary); sum != "" {
parts = append(parts, "前情重點:"+sum)
}
// 最近訊息(略過產文短紀錄)
msgs := sess.Messages
start := 0
if len(msgs) > 10 {
start = len(msgs) - 10
}
for _, m := range msgs[start:] {
t := strings.TrimSpace(m.Text)
if strings.HasPrefix(t, "【產文】") {
continue
}
if m.Role == "user" && t != "" {
parts = append(parts, "我想:"+truncate(t, 160))
} else if m.Role == "assistant" {
if m.Draft != nil && strings.TrimSpace(m.Draft.Body) != "" {
parts = append(parts, "先前草稿:"+truncate(m.Draft.Body, 200))
} else if t != "" && t != "已依人設改寫成草稿,可直接用。" && t != "已產出草稿,可直接用。" {
parts = append(parts, "討論:"+truncate(t, 200))
}
}
}
}
if e := strings.TrimSpace(extra); e != "" {
parts = append(parts, "補充:"+e)
}
return strings.TrimSpace(strings.Join(parts, "\n\n"))
}
func (s *Service) resolvePersonaSnap(ctx context.Context, ownerUID int64, personaID string) *PersonaSnapshot {
if s.Personas == nil {
return nil
}
p, err := s.Personas.ResolvePersona(ctx, ownerUID, personaID)
if err != nil {
return nil
}
return p
}
// inferPersonaLanguage — 從人設指紋/範例推輸出語言;空=沿用會員 UI 語系。
func inferPersonaLanguage(p *PersonaSnapshot) string {
if p == nil {
return ""
}
var texts []string
for _, t := range []string{p.DraftText, p.Voice, p.Brief, p.Name} {
if strings.TrimSpace(t) != "" {
texts = append(texts, t)
}
}
for _, sm := range p.DimSummaries {
if strings.TrimSpace(sm) != "" {
texts = append(texts, sm)
}
}
return ai.InferScriptLanguage(texts...)
}
func inspireSystemRules(mode, lang string, maxChars int) string {
en := ai.NormalizeResponseLanguage(lang) == "en"
if mode == "generate" {
lenRuleZh := "約 80220 字"
lenRuleEn := "About 80220 words"
if maxChars > 0 {
lenRuleZh = fmt.Sprintf("嚴格約不超過 %d 字(字元)", maxChars)
lenRuleEn = fmt.Sprintf("Hard cap about %d characters", maxChars)
}
if en {
return strings.Join([]string{
"Rewrite 【Content to rewrite】 into one Threads post in the persona's voice.",
"Use 【Persona】 only for how they sound (tone / rhythm / wording). Just rewrite — do not over-engineer openings or force templates.",
"Keep the material's topic and facts. No analysis, no titles, no markdown, no outline.",
"Output ONLY the post body. " + lenRuleEn + ". Do not invent brands not in material/pins.",
}, "\n")
}
return strings.Join([]string{
"依【待改寫內容】用人設口吻重寫成一則 Threads 正文。",
"【人設】只決定「聽起來像誰」;直接重寫即可,不必設計固定開頭、不必套模板。",
"主題與事實以素材為準。不要分析、標題、markdown、大綱。",
"只輸出正文。" + lenRuleZh + "。未在素材pin 出現的品牌勿捏造。",
}, "\n")
}
// chat正常發想對話不限 Threads 字數、不強壓極短(完整討論)
if en {
return strings.Join([]string{
"You are a Threads ideation partner, not a final-copy writer.",
"Chat naturally and help brainstorm: topic, angle, emotion, hooks, examples, taboos.",
"Reply as long as needed for good ideation (no post character limit here).",
"You may offer options, questions, and partial lines; do not force a final publish-ready post unless asked.",
"When consensus is clear, you may summarize as “Current consensus: …”.",
"Language: match the persona (fingerprint/samples).",
"Do not cite brands unless pinned.",
}, "\n")
}
return strings.Join([]string{
"你是 Threads 發想搭子,不是定稿寫手。",
"用正常聊天方式一起發想:主題、角度、情緒、鉤子、例子、禁忌都可以展開談。",
"發想階段不限字數、不必刻意壓短;把討論講完整比較重要。",
"可以給方向、選項、半成品句子;除非用戶要求,不要硬寫成最終可貼文定稿。",
"共識清楚時可用「目前共識:…」整理。",
"語言與【人設】一致(指紋/範例);不要被 UI 語系帶跑。",
"未套用品牌勿引用。",
}, "\n")
}
// buildInspirePrompt 組裝真實送 AI 的全文。
// chat發想generate對【待改寫內容】做人設改寫。
// persona 可預先 resolvelang = zh-TW | en人設優先
func (s *Service) buildInspirePrompt(ctx context.Context, ownerUID int64, pinnedIDs []string, message, mode, personaID, material string, sess *domain.Session, persona *PersonaSnapshot, lang string) (string, []PromptBlock, []string) {
var blocks []PromptBlock
var sections []string
add := func(title, body string) {
body = strings.TrimSpace(body)
if body == "" {
return
}
blocks = append(blocks, PromptBlock{Title: title, Body: body})
sections = append(sections, title)
}
lang = ai.NormalizeResponseLanguage(lang)
maxChars := 0
if persona != nil && mode == "generate" {
maxChars = persona.MaxChars
}
add("系統規則", inspireSystemRules(mode, lang, maxChars))
if pBlock := formatPersonaPromptBlock(persona, mode, lang); pBlock != "" {
add("人設", pBlock)
} else if pBlock := s.personaPromptBlock(ctx, ownerUID, personaID, mode); pBlock != "" {
// fallback if snap nil
add("人設", pBlock)
}
// 品牌/產品:只有 pin 才帶chat 截更短以加快
pinCap := inspirePinBodyGenerate
if mode == "chat" {
pinCap = inspirePinBodyChat
}
var ctxParts []string
var pinnedBrandIDs []string
for _, id := range pinnedIDs {
if e, eerr := s.Repo.GetElement(ctx, id); eerr == nil && e != nil && e.OwnerUID == ownerUID {
kind := strings.TrimSpace(e.Kind)
body := strings.TrimSpace(e.Body)
if len([]rune(body)) > pinCap {
body = string([]rune(body)[:pinCap]) + "…"
}
if kind != "" {
ctxParts = append(ctxParts, fmt.Sprintf("[%s] %s\n%s", kind, e.Title, body))
} else {
ctxParts = append(ctxParts, e.Title+":\n"+body)
}
if kind == "brand" || strings.HasPrefix(e.ID, "el_brand_") {
bid := strings.TrimSpace(e.RefID)
if bid == "" && strings.HasPrefix(e.ID, "el_brand_") {
bid = strings.TrimPrefix(e.ID, "el_brand_")
}
if bid != "" {
pinnedBrandIDs = append(pinnedBrandIDs, bid)
}
}
}
}
if cat := s.brandCatalogBlock(ctx, ownerUID, pinnedBrandIDs); cat != "" {
add("品牌與產品", truncate(cat, 900))
}
if len(ctxParts) > 0 {
joined := strings.Join(ctxParts, "\n\n")
if len([]rune(joined)) > 1200 {
joined = string([]rune(joined)[:1200]) + "…"
}
add("套用元素", joined)
}
if mode == "generate" {
mat := strings.TrimSpace(material)
if mat == "" {
mat = ComposeDraftMaterial(sess, message)
}
add("待改寫內容", truncate(mat, 2000))
notes := strings.TrimSpace(message)
if notes == "" {
notes = "用人設寫成 Threads 正文"
}
add("改寫指示", notes)
} else {
if sess != nil {
if sum := strings.TrimSpace(sess.ContextSummary); sum != "" {
add("前情摘要", sum)
}
}
if sess != nil && len(sess.Messages) > 0 {
hist := sess.Messages
if len(hist) > 0 && hist[len(hist)-1].Role == "user" {
last := strings.TrimSpace(hist[len(hist)-1].Text)
if last == strings.TrimSpace(message) || strings.HasPrefix(last, "【產文】") {
hist = hist[:len(hist)-1]
}
}
keep := inspireKeepRecentChat - 1
if keep < 2 {
keep = 2
}
start := 0
if len(hist) > keep {
start = len(hist) - keep
}
if start < len(hist) {
var hb strings.Builder
for _, m := range hist[start:] {
role := "使用者"
if m.Role == "assistant" {
role = "助手"
}
t := strings.TrimSpace(m.Text)
if t == "" && m.Draft != nil {
t = m.Draft.Body
}
if strings.HasPrefix(t, "【產文】") {
continue
}
t = truncate(t, inspireHistoryLineChat)
hb.WriteString(role)
hb.WriteString("")
hb.WriteString(t)
hb.WriteString("\n")
}
if s := strings.TrimSpace(hb.String()); s != "" {
add("最近對話", s)
}
}
}
add("本輪", message)
}
var b strings.Builder
for i, blk := range blocks {
if i > 0 {
b.WriteString("\n")
}
b.WriteString("【")
b.WriteString(blk.Title)
b.WriteString("】\n")
b.WriteString(blk.Body)
b.WriteString("\n")
}
full := b.String()
capN := inspirePromptCapGenerate
if mode == "chat" {
capN = inspirePromptCapChat
}
if len([]rune(full)) > capN {
full = string([]rune(full)[:capN]) + "…\n"
}
return full, blocks, sections
}
// personaPromptBlock聊天用輕量人設產文帶指紋但截斷。
func (s *Service) personaPromptBlock(ctx context.Context, ownerUID int64, personaID, mode string) string {
p := s.resolvePersonaSnap(ctx, ownerUID, personaID)
lang := inferPersonaLanguage(p)
if lang == "" {
lang = ai.ResponseLanguageFrom(ctx)
}
return formatPersonaPromptBlock(p, mode, lang)
}
// formatPersonaPromptBlock 組人設段,並明示輸出語言。
func formatPersonaPromptBlock(p *PersonaSnapshot, mode, lang string) string {
if p == nil {
return ""
}
lang = ai.NormalizeResponseLanguage(lang)
en := lang == "en"
var b strings.Builder
if en {
b.WriteString("Output language: English (match persona samples/fingerprint; do not switch to Chinese unless material is Chinese-only).\n")
} else {
b.WriteString("輸出語言:繁體中文(台灣),除非人設指紋/範例明顯是其他語言——以人設為準。\n")
}
if n := strings.TrimSpace(p.Name); n != "" {
if en {
b.WriteString("Name: ")
} else {
b.WriteString("名稱:")
}
b.WriteString(n)
b.WriteString("\n")
}
if br := strings.TrimSpace(p.Brief); br != "" {
if en {
b.WriteString("Positioning: ")
} else {
b.WriteString("定位:")
}
b.WriteString(truncate(br, 120))
b.WriteString("\n")
}
if v := strings.TrimSpace(p.Voice); v != "" {
if en {
b.WriteString("Voice: ")
} else {
b.WriteString("語氣:")
}
b.WriteString(truncate(v, 80))
b.WriteString("\n")
}
if mode == "chat" {
if len(p.GuardAvoid) > 0 {
if en {
b.WriteString("Avoid: ")
} else {
b.WriteString("禁止:")
}
b.WriteString(strings.Join(p.GuardAvoid, "、"))
b.WriteString("\n")
}
if p.BanAiTone {
if en {
b.WriteString("No AI/customer-service tone\n")
} else {
b.WriteString("禁止 AI 腔/客服腔\n")
}
}
return strings.TrimSpace(b.String())
}
fp := strings.TrimSpace(p.DraftText)
if fp != "" {
if en {
b.WriteString("【Language fingerprint】\n")
} else {
b.WriteString("【語言指紋】\n")
}
b.WriteString(truncate(fp, 700))
b.WriteString("\n")
}
if len(p.DimSummaries) > 0 {
var lines []string
for _, k := range []string{"d1Tone", "d2Structure", "d4Topics"} {
if sm := strings.TrimSpace(p.DimSummaries[k]); sm != "" {
lines = append(lines, k+": "+truncate(sm, 80))
}
}
if len(lines) > 0 {
if en {
b.WriteString("【Style notes】\n")
} else {
b.WriteString("【風格重點】\n")
}
b.WriteString(strings.Join(lines, "\n"))
b.WriteString("\n")
}
}
var guard []string
if len(p.GuardAvoid) > 0 {
prefix := "禁止:"
if en {
prefix = "Avoid: "
}
guard = append(guard, prefix+strings.Join(p.GuardAvoid, "、"))
}
if p.BanAiTone {
if en {
guard = append(guard, "No AI/customer-service tone")
} else {
guard = append(guard, "禁止 AI 腔/客服腔")
}
}
// 字數上限只在產文generate生效發想聊天不塞字數讓回覆更快
if mode == "generate" && p.MaxChars > 0 {
if en {
guard = append(guard, fmt.Sprintf("About %d characters max", p.MaxChars))
} else {
guard = append(guard, fmt.Sprintf("約不超過 %d 字", p.MaxChars))
}
}
if len(guard) > 0 {
b.WriteString("【護欄】\n")
b.WriteString(strings.Join(guard, "\n"))
b.WriteString("\n")
}
return strings.TrimSpace(b.String())
}
// brandCatalogBlock 只輸出 allowIDs 內的品牌(空 = 不帶任何品牌)。
func (s *Service) brandCatalogBlock(ctx context.Context, ownerUID int64, allowIDs []string) string {
if s.Brands == nil || len(allowIDs) == 0 {
return ""
}
want := map[string]struct{}{}
for _, id := range allowIDs {
id = strings.TrimSpace(id)
if id != "" {
want[id] = struct{}{}
}
}
if len(want) == 0 {
return ""
}
list, err := s.Brands.ListCatalog(ctx, ownerUID)
if err != nil || len(list) == 0 {
return ""
}
var b strings.Builder
n := 0
for _, br := range list {
if _, ok := want[br.ID]; !ok {
continue
}
if n > 0 {
b.WriteString("\n")
}
n++
name := strings.TrimSpace(br.DisplayName)
if name == "" {
name = br.ID
}
b.WriteString("■ 品牌:")
b.WriteString(name)
b.WriteString("\n")
if t := strings.TrimSpace(br.Brief); t != "" {
b.WriteString("簡介:")
b.WriteString(t)
b.WriteString("\n")
}
if t := strings.TrimSpace(br.Audience); t != "" {
b.WriteString("受眾:")
b.WriteString(t)
b.WriteString("\n")
}
if t := strings.TrimSpace(br.Goals); t != "" {
b.WriteString("目標:")
b.WriteString(t)
b.WriteString("\n")
}
if len(br.Products) == 0 {
b.WriteString("(此品牌尚無產品)\n")
continue
}
for _, pr := range br.Products {
label := strings.TrimSpace(pr.Label)
if label == "" {
label = pr.ID
}
b.WriteString(" · 產品:")
b.WriteString(label)
b.WriteString("\n")
if t := strings.TrimSpace(pr.Context); t != "" {
b.WriteString(" 說明:")
b.WriteString(t)
b.WriteString("\n")
}
if len(pr.Tags) > 0 {
b.WriteString(" 標籤:")
b.WriteString(strings.Join(pr.Tags, "、"))
b.WriteString("\n")
}
if len(pr.Pains) > 0 {
b.WriteString(" 痛點:")
b.WriteString(strings.Join(pr.Pains, "、"))
b.WriteString("\n")
}
}
}
return strings.TrimSpace(b.String())
}
func (s *Service) runInspireLLM(ctx context.Context, ownerUID int64, prompt string, onDelta func(string) error) (string, error) {
// 真 AIRegistry + 會員 provider/model/key
if s.ResolveAI != nil && s.AIRegistry != nil {
provider, model, apiKey, err := s.ResolveAI(ctx, ownerUID)
if err == nil && strings.TrimSpace(apiKey) != "" && !strings.HasPrefix(strings.ToLower(apiKey), "fake") {
c, cerr := s.AIRegistry.Client(provider)
if cerr == nil {
if onDelta != nil {
return c.CompleteStream(ctx, apiKey, model, prompt, onDelta)
}
return c.Complete(ctx, apiKey, model, prompt)
}
}
}
// 測試 FakeClient
if s.AI != nil {
key := "test-key"
if s.ResolveKey != nil {
if _, k, rerr := s.ResolveKey(ctx, ownerUID, usageDomain.MeterAICopy); rerr == nil && k != "" {
key = k
}
}
if onDelta != nil {
return s.AI.CompleteStream(ctx, key, "grok-3", prompt, onDelta)
}
return s.AI.Complete(ctx, key, "grok-3", prompt)
}
return "", fmt.Errorf("%w: 請到設定填寫 AI Key", domain.ErrValidation)
}
func (s *Service) ResearchSearch(ctx context.Context, ownerUID int64, query string) ([]*domain.ResearchHit, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, fmt.Errorf("%w: empty query", domain.ErrValidation)
}
if err := s.bill(ctx, ownerUID, usageDomain.MeterWebSearch, "research search", "research.search"); err != nil {
return nil, err
}
key := "fake"
if s.ResolveKey != nil {
if _, k, err := s.ResolveKey(ctx, ownerUID, usageDomain.MeterWebSearch); err == nil && k != "" {
key = k
}
}
var raw []search.Hit
if s.Search != nil {
h, err := s.Search.Search(ctx, key, query, 5)
if err != nil {
return nil, err
}
raw = h
} else {
raw = []search.Hit{{Title: "About " + query, URL: "https://example.com", Snippet: "snippet"}}
}
out := make([]*domain.ResearchHit, 0, len(raw))
for i, h := range raw {
out = append(out, &domain.ResearchHit{
ID: "rh_" + uuid.NewString()[:8], Title: h.Title, Snippet: h.Snippet, URL: h.URL,
Summary: h.Snippet, LearnPoints: []string{"重點 " + fmt.Sprint(i+1), "可引用觀點"},
ReplyHooks: []string{"你也覺得" + query + "嗎?"}, SourceLabel: "exa",
})
}
return out, nil
}
func (s *Service) GenerateImage(ctx context.Context, ownerUID int64, prompt string) (*domain.GeneratedImage, error) {
prompt = strings.TrimSpace(prompt)
if prompt == "" {
return nil, fmt.Errorf("%w: empty prompt", domain.ErrValidation)
}
if err := s.bill(ctx, ownerUID, usageDomain.MeterAIImage, "generate image", "media.generateImage"); err != nil {
return nil, err
}
// placeholder SVG data URL (live-complete with billable path; real SD later)
id := "img_" + uuid.NewString()[:10]
// minimal 1x1 png base64 is tiny; use dicebear-like public URL for preview
url := fmt.Sprintf("https://api.dicebear.com/9.x/shapes/svg?seed=%s", id)
return &domain.GeneratedImage{ID: id, URL: url, Prompt: prompt}, nil
}
// Legacy removed APIs
func (s *Service) LegacyRemoved() error { return domain.ErrRemoved }
func (s *Service) bill(ctx context.Context, uid int64, meter, label, source string) error {
if s.Usage == nil {
return nil
}
mode, err := s.Usage.PrepareCall(ctx, uid, meter)
if err != nil {
return err
}
_, err = s.Usage.RecordCall(ctx, uid, meter, mode, label, source)
return err
}
func emptySession(ownerUID int64) *domain.Session {
now := domain.NowNano()
return &domain.Session{
ID: "sess_" + uuid.NewString()[:8],
OwnerUID: ownerUID,
Title: "",
Messages: []domain.ChatMessage{},
PinnedElementIDs: []string{},
ContextSummary: "",
SummaryMsgCount: 0,
CreatedAt: now,
UpdatedAt: now,
}
}
func defaultTrends(ownerUID int64) []*domain.TrendItem {
now := domain.NowNano()
// 僅 live 失敗時的後備SourceLabel=seedUI 應標示「示意」而非 Threads 即時榜
return []*domain.TrendItem{
{ID: "tr_seed_1", OwnerUID: ownerUID, Kind: "threads_tag", Label: "敏感肌換季", Summary: "示意話題(網搜未就緒)", Heat: 1,
Keywords: []string{"敏感肌"}, Samples: []string{"換季又紅了…"}, SourceLabel: "seed", ObservedAt: now},
{ID: "tr_seed_2", OwnerUID: ownerUID, Kind: "web_keyword", Label: "遠端上班", Summary: "示意話題(網搜未就緒)", Heat: 2,
Keywords: []string{"遠端"}, Samples: []string{"居家辦公第三年"}, SourceLabel: "seed", ObservedAt: now},
{ID: "tr_seed_3", OwnerUID: ownerUID, Kind: "news", Label: "無香洗髮", Summary: "示意話題(網搜未就緒)", Heat: 3,
Keywords: []string{"無香"}, Samples: []string{"終於找到不刺鼻的"}, SourceLabel: "seed", ObservedAt: now},
}
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}