1479 lines
44 KiB
Go
1479 lines
44 KiB
Go
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(與 Chat/ChatStream 同一 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 stream;onDelta 每收到一段正文就回呼(可 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 := "約 80~220 字"
|
||
lenRuleEn := "About 80–220 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 可預先 resolve;lang = 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) {
|
||
// 真 AI:Registry + 會員 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=seed,UI 應標示「示意」而非 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]) + "…"
|
||
}
|