2026-07-02 14:52:41 +00:00
|
|
|
// Code scaffolded by goctl. Safe to edit.
|
|
|
|
|
// goctl 1.10.1
|
|
|
|
|
|
2026-06-26 08:37:04 +00:00
|
|
|
package copy_mission
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"strings"
|
2026-07-02 14:52:41 +00:00
|
|
|
"time"
|
2026-06-26 08:37:04 +00:00
|
|
|
|
|
|
|
|
app "haixun-backend/internal/library/errors"
|
|
|
|
|
"haixun-backend/internal/library/errors/code"
|
|
|
|
|
"haixun-backend/internal/library/placement"
|
|
|
|
|
"haixun-backend/internal/library/style8d"
|
|
|
|
|
libviral "haixun-backend/internal/library/viral"
|
|
|
|
|
"haixun-backend/internal/library/websearch"
|
|
|
|
|
domai "haixun-backend/internal/model/ai/domain/usecase"
|
|
|
|
|
aiusecase "haixun-backend/internal/model/ai/usecase"
|
2026-07-02 14:52:41 +00:00
|
|
|
contentops "haixun-backend/internal/model/content_ops/domain/usecase"
|
|
|
|
|
personadomain "haixun-backend/internal/model/persona/domain/usecase"
|
2026-06-26 08:37:04 +00:00
|
|
|
"haixun-backend/internal/svc"
|
|
|
|
|
"haixun-backend/internal/types"
|
2026-07-02 14:52:41 +00:00
|
|
|
|
|
|
|
|
"github.com/zeromicro/go-zero/core/logx"
|
2026-06-26 08:37:04 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type InspireCopyMissionLogic struct {
|
2026-07-02 14:52:41 +00:00
|
|
|
logx.Logger
|
2026-06-26 08:37:04 +00:00
|
|
|
ctx context.Context
|
|
|
|
|
svcCtx *svc.ServiceContext
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewInspireCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *InspireCopyMissionLogic {
|
2026-07-02 14:52:41 +00:00
|
|
|
return &InspireCopyMissionLogic{
|
|
|
|
|
Logger: logx.WithContext(ctx),
|
|
|
|
|
ctx: ctx,
|
|
|
|
|
svcCtx: svcCtx,
|
|
|
|
|
}
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-02 14:52:41 +00:00
|
|
|
func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspirationHandlerReq) (resp *types.CopyMissionInspirationData, err error) {
|
2026-06-26 08:37:04 +00:00
|
|
|
tenantID, uid, err := actorFrom(l.ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
personaID := strings.TrimSpace(req.PersonaID)
|
2026-07-02 14:52:41 +00:00
|
|
|
if personaID == "" {
|
|
|
|
|
return nil, app.For(code.Persona).InputMissingRequired("persona_id is required")
|
|
|
|
|
}
|
2026-06-26 08:37:04 +00:00
|
|
|
persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-07-02 14:52:41 +00:00
|
|
|
credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
|
2026-06-26 08:37:04 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-07-02 14:52:41 +00:00
|
|
|
providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-02 14:52:41 +00:00
|
|
|
keyword := strings.TrimSpace(req.Keyword)
|
|
|
|
|
trends, sources, searchUsed := []libviral.MissionInspireTrendSnippet(nil), []types.CopyMissionInspirationSourceData(nil), false
|
|
|
|
|
if req.UseWebSearch {
|
|
|
|
|
trends, sources, searchUsed = l.collectTrendSignals(tenantID, uid, persona, keyword)
|
|
|
|
|
}
|
|
|
|
|
llmOnly := len(trends) == 0
|
|
|
|
|
if keyword != "" && llmOnly {
|
|
|
|
|
trends = append(trends, libviral.MissionInspireTrendSnippet{
|
|
|
|
|
Query: keyword,
|
|
|
|
|
Title: "使用者指定方向",
|
|
|
|
|
Snippet: keyword,
|
|
|
|
|
})
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-02 14:52:41 +00:00
|
|
|
personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief)
|
|
|
|
|
temp := 0.72
|
|
|
|
|
result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{
|
|
|
|
|
Provider: providerID,
|
|
|
|
|
Model: credential.Model,
|
|
|
|
|
Credential: domai.Credential{APIKey: credential.APIKey},
|
|
|
|
|
System: libviral.BuildMissionInspireSystemPrompt(),
|
|
|
|
|
Temperature: &temp,
|
|
|
|
|
Messages: []domai.Message{{Role: "user", Content: libviral.BuildMissionInspireUserPrompt(libviral.MissionInspireInput{
|
|
|
|
|
PersonaDisplayName: persona.DisplayName,
|
|
|
|
|
PersonaBrief: persona.Brief,
|
|
|
|
|
PersonaBlock: personaBlock,
|
|
|
|
|
StyleBenchmark: persona.StyleBenchmark,
|
|
|
|
|
AvoidTopics: cleanAvoidTopics(req.AvoidTopics),
|
|
|
|
|
ContentDirection: req.ContentDirection,
|
|
|
|
|
UserKeyword: keyword,
|
|
|
|
|
TrendSnippets: trends,
|
|
|
|
|
WebSearchProvider: sourceLabel(searchUsed, sources),
|
|
|
|
|
LLMOnly: llmOnly,
|
|
|
|
|
})}},
|
|
|
|
|
})
|
2026-06-26 08:37:04 +00:00
|
|
|
if err != nil {
|
2026-07-02 14:52:41 +00:00
|
|
|
return nil, app.For(code.AI).SvcThirdParty("起號靈感生成失敗:" + err.Error())
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
2026-07-02 14:52:41 +00:00
|
|
|
parsed, err := libviral.ParseMissionInspireOutput(result.Text)
|
2026-06-26 08:37:04 +00:00
|
|
|
if err != nil {
|
2026-07-02 14:52:41 +00:00
|
|
|
return nil, app.For(code.AI).SvcThirdParty("起號靈感回傳無法解析:" + err.Error())
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-02 14:52:41 +00:00
|
|
|
topicCandidate, plan := l.persistInspiration(tenantID, uid, personaID, parsed, searchUsed)
|
|
|
|
|
return &types.CopyMissionInspirationData{
|
|
|
|
|
TopicCandidateID: topicCandidate,
|
|
|
|
|
ContentPlanID: plan,
|
|
|
|
|
Label: parsed.Label,
|
|
|
|
|
SeedQuery: parsed.SeedQuery,
|
|
|
|
|
Brief: parsed.Brief,
|
|
|
|
|
TrendReason: parsed.TrendReason,
|
|
|
|
|
TrendKeywords: parsed.TrendKeywords,
|
|
|
|
|
Angles: parsed.Angles,
|
|
|
|
|
Mission: parsed.Mission,
|
|
|
|
|
TargetAudience: parsed.TargetAudience,
|
|
|
|
|
OpeningType: parsed.OpeningType,
|
|
|
|
|
BodyType: parsed.BodyType,
|
|
|
|
|
Emotion: parsed.Emotion,
|
|
|
|
|
CtaType: parsed.CtaType,
|
|
|
|
|
RiskLevel: parsed.RiskLevel,
|
|
|
|
|
Avoid: parsed.Avoid,
|
|
|
|
|
Sources: sources,
|
|
|
|
|
WebSearchUsed: searchUsed,
|
|
|
|
|
Message: "已找到一個可延伸的話題與內容計畫",
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (l *InspireCopyMissionLogic) persistInspiration(tenantID, uid, personaID string, parsed libviral.MissionInspireOutput, searchUsed bool) (string, string) {
|
|
|
|
|
if l.svcCtx.ContentOps == nil {
|
|
|
|
|
return "", ""
|
|
|
|
|
}
|
|
|
|
|
riskScore := riskScoreFromLevel(parsed.RiskLevel)
|
|
|
|
|
category := parsed.Mission
|
|
|
|
|
if category == "" {
|
|
|
|
|
category = "候選話題"
|
|
|
|
|
}
|
|
|
|
|
source := "llm"
|
|
|
|
|
if searchUsed {
|
|
|
|
|
source = "topic_radar"
|
|
|
|
|
}
|
|
|
|
|
topic, err := l.svcCtx.ContentOps.CreateTopicCandidate(l.ctx, contentops.TopicCandidateInput{
|
|
|
|
|
TenantID: tenantID,
|
|
|
|
|
OwnerUID: uid,
|
|
|
|
|
PersonaID: personaID,
|
|
|
|
|
Name: parsed.Label,
|
|
|
|
|
Source: source,
|
|
|
|
|
Category: category,
|
|
|
|
|
SeedQuery: parsed.SeedQuery,
|
|
|
|
|
TrendReason: parsed.TrendReason,
|
|
|
|
|
TrendKeywords: parsed.TrendKeywords,
|
|
|
|
|
HeatScore: scoreBySearch(searchUsed),
|
|
|
|
|
FitScore: 82,
|
|
|
|
|
InteractionScore: 70,
|
|
|
|
|
ExtendScore: 78,
|
|
|
|
|
RiskScore: riskScore,
|
|
|
|
|
RecommendedMissions: []string{parsed.Mission},
|
2026-06-26 08:37:04 +00:00
|
|
|
})
|
|
|
|
|
if err != nil {
|
2026-07-02 14:52:41 +00:00
|
|
|
l.Errorf("persist topic candidate: %v", err)
|
|
|
|
|
return "", ""
|
|
|
|
|
}
|
|
|
|
|
angle := ""
|
|
|
|
|
if len(parsed.Angles) > 0 {
|
|
|
|
|
angle = parsed.Angles[0]
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
2026-07-02 14:52:41 +00:00
|
|
|
plan, err := l.svcCtx.ContentOps.CreateContentPlan(l.ctx, contentops.ContentPlanInput{
|
|
|
|
|
TenantID: tenantID,
|
|
|
|
|
OwnerUID: uid,
|
|
|
|
|
PersonaID: personaID,
|
|
|
|
|
TopicCandidateID: topic.ID,
|
|
|
|
|
Topic: parsed.Label,
|
|
|
|
|
Mission: fallbackString(parsed.Mission, "互動文"),
|
|
|
|
|
TargetAudience: parsed.TargetAudience,
|
|
|
|
|
Angle: angle,
|
|
|
|
|
OpeningType: parsed.OpeningType,
|
|
|
|
|
BodyType: parsed.BodyType,
|
|
|
|
|
Emotion: parsed.Emotion,
|
|
|
|
|
CtaType: parsed.CtaType,
|
|
|
|
|
RiskLevel: parsed.RiskLevel,
|
|
|
|
|
RequiresHumanReview: parsed.RiskLevel != "low",
|
|
|
|
|
Avoid: parsed.Avoid,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
l.Errorf("persist content plan: %v", err)
|
|
|
|
|
return topic.ID, ""
|
|
|
|
|
}
|
|
|
|
|
return topic.ID, plan.ID
|
|
|
|
|
}
|
2026-06-26 08:37:04 +00:00
|
|
|
|
2026-07-02 14:52:41 +00:00
|
|
|
func scoreBySearch(searchUsed bool) int {
|
|
|
|
|
if searchUsed {
|
|
|
|
|
return 76
|
|
|
|
|
}
|
|
|
|
|
return 55
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func riskScoreFromLevel(level string) int {
|
|
|
|
|
switch strings.ToLower(strings.TrimSpace(level)) {
|
|
|
|
|
case "high":
|
|
|
|
|
return 75
|
|
|
|
|
case "low":
|
|
|
|
|
return 20
|
|
|
|
|
default:
|
|
|
|
|
return 45
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func fallbackString(value, fallback string) string {
|
|
|
|
|
if strings.TrimSpace(value) != "" {
|
|
|
|
|
return strings.TrimSpace(value)
|
|
|
|
|
}
|
|
|
|
|
return fallback
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (l *InspireCopyMissionLogic) collectTrendSignals(tenantID, uid string, persona *personadomain.PersonaSummary, keyword string) ([]libviral.MissionInspireTrendSnippet, []types.CopyMissionInspirationSourceData, bool) {
|
|
|
|
|
if persona == nil {
|
|
|
|
|
return nil, nil, false
|
|
|
|
|
}
|
|
|
|
|
trends := make([]libviral.MissionInspireTrendSnippet, 0, 12)
|
|
|
|
|
seen := map[string]struct{}{}
|
|
|
|
|
addTrend := func(item libviral.MissionInspireTrendSnippet) {
|
|
|
|
|
item.Query = strings.TrimSpace(item.Query)
|
|
|
|
|
item.Title = strings.TrimSpace(item.Title)
|
|
|
|
|
item.Snippet = strings.TrimSpace(item.Snippet)
|
|
|
|
|
item.URL = strings.TrimSpace(item.URL)
|
|
|
|
|
if item.Title == "" && item.Snippet == "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if isNewsLikeInspiration(item.Title, item.Snippet, item.URL) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
key := strings.ToLower(item.Query + "|" + item.Title + "|" + item.Snippet)
|
|
|
|
|
if _, ok := seen[key]; ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
seen[key] = struct{}{}
|
|
|
|
|
trends = append(trends, item)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, item := range libviral.CollectFreeTopicTrends(l.ctx, keyword, persona.Brief) {
|
|
|
|
|
addTrend(item)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
|
|
|
|
|
if err != nil {
|
|
|
|
|
sources := inspirationSourcesFromTrends(trends)
|
|
|
|
|
return trends, sources, len(sources) > 0
|
|
|
|
|
}
|
|
|
|
|
memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
|
2026-06-26 08:37:04 +00:00
|
|
|
if err != nil {
|
2026-07-02 14:52:41 +00:00
|
|
|
sources := inspirationSourcesFromTrends(trends)
|
|
|
|
|
return trends, sources, len(sources) > 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if placement.WebSearchAvailable(research) {
|
|
|
|
|
client := websearch.New(memberCtx.WebSearchConfig())
|
|
|
|
|
for _, item := range libviral.CollectMissionInspireTrends(l.ctx, client, memberCtx, strings.TrimSpace(persona.Brief+" "+keyword), persona.StyleBenchmark) {
|
|
|
|
|
addTrend(item)
|
|
|
|
|
}
|
|
|
|
|
if keyword != "" && client.Enabled() {
|
|
|
|
|
for _, query := range []string{
|
|
|
|
|
"Threads 台灣 " + keyword + " 熱門 關鍵字 討論",
|
|
|
|
|
keyword + " 請問 心得 推薦 Threads",
|
|
|
|
|
keyword + " 痛點 經驗 怎麼辦 社群",
|
|
|
|
|
keyword + " Dcard PTT Threads 熱門 討論",
|
|
|
|
|
} {
|
|
|
|
|
res, searchErr := client.Search(l.ctx, websearch.SearchOptions{
|
|
|
|
|
Query: query,
|
|
|
|
|
Limit: 4,
|
|
|
|
|
Mode: websearch.ModeKnowledgeExpand,
|
|
|
|
|
Country: memberCtx.BraveCountry,
|
|
|
|
|
SearchLang: memberCtx.BraveSearchLang,
|
|
|
|
|
UserLocation: memberCtx.ExaUserLocation,
|
|
|
|
|
StartPublishedDate: placement.FormatPublishedAfterISO(14, time.Now().UTC()),
|
|
|
|
|
})
|
|
|
|
|
if searchErr != nil || res.Status != "success" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
for _, result := range res.Results {
|
|
|
|
|
addTrend(libviral.MissionInspireTrendSnippet{
|
|
|
|
|
Query: query,
|
|
|
|
|
Title: result.Title,
|
|
|
|
|
Snippet: result.Snippet,
|
|
|
|
|
URL: result.URL,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-02 14:52:41 +00:00
|
|
|
if keyword != "" && len(trends) < 8 {
|
|
|
|
|
for _, item := range libviral.CollectMissionInspireThreadsTrends(l.ctx, libviral.MissionInspireThreadsInput{
|
|
|
|
|
Keyword: keyword,
|
|
|
|
|
PersonaBrief: persona.Brief,
|
|
|
|
|
StyleBenchmark: persona.StyleBenchmark,
|
|
|
|
|
Pillars: persona.CopyResearchMap.Pillars,
|
|
|
|
|
Questions: persona.CopyResearchMap.Questions,
|
|
|
|
|
Member: memberCtx,
|
|
|
|
|
}) {
|
|
|
|
|
addTrend(item)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(trends) > 10 {
|
|
|
|
|
trends = trends[:10]
|
|
|
|
|
}
|
|
|
|
|
sources := inspirationSourcesFromTrends(trends)
|
|
|
|
|
return trends, sources, len(sources) > 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func isNewsLikeInspiration(title, snippet, url string) bool {
|
|
|
|
|
text := strings.ToLower(title + " " + snippet + " " + url)
|
|
|
|
|
newsSignals := []string{"新聞", "快訊", "中央社", "聯合新聞", "ettoday", "tvbs", "setn", "ctwant", "chinatimes", "ltn.com", "news"}
|
|
|
|
|
socialSignals := []string{"threads", "dcard", "ptt", "請問", "心得", "推薦", "有人", "怎麼辦", "討論", "留言"}
|
|
|
|
|
newsScore := 0
|
|
|
|
|
for _, signal := range newsSignals {
|
|
|
|
|
if strings.Contains(text, strings.ToLower(signal)) {
|
|
|
|
|
newsScore++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if newsScore == 0 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
for _, signal := range socialSignals {
|
|
|
|
|
if strings.Contains(text, strings.ToLower(signal)) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func inspirationSourcesFromTrends(trends []libviral.MissionInspireTrendSnippet) []types.CopyMissionInspirationSourceData {
|
|
|
|
|
sources := make([]types.CopyMissionInspirationSourceData, 0, len(trends))
|
|
|
|
|
for _, item := range trends {
|
2026-06-26 08:37:04 +00:00
|
|
|
sources = append(sources, types.CopyMissionInspirationSourceData{
|
|
|
|
|
Query: item.Query,
|
|
|
|
|
Title: item.Title,
|
|
|
|
|
Snippet: item.Snippet,
|
|
|
|
|
URL: item.URL,
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-07-02 14:52:41 +00:00
|
|
|
return sources
|
|
|
|
|
}
|
2026-06-26 08:37:04 +00:00
|
|
|
|
2026-07-02 14:52:41 +00:00
|
|
|
func sourceLabel(used bool, sources []types.CopyMissionInspirationSourceData) string {
|
|
|
|
|
if !used || len(sources) == 0 {
|
|
|
|
|
return ""
|
2026-06-28 08:28:42 +00:00
|
|
|
}
|
2026-07-02 14:52:41 +00:00
|
|
|
return "Free Trend / Web / Threads Search"
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
2026-06-28 08:28:42 +00:00
|
|
|
|
2026-07-02 14:52:41 +00:00
|
|
|
func cleanAvoidTopics(items []string) []string {
|
|
|
|
|
out := make([]string, 0, len(items))
|
|
|
|
|
seen := map[string]struct{}{}
|
|
|
|
|
for _, item := range items {
|
|
|
|
|
item = strings.TrimSpace(item)
|
|
|
|
|
if item == "" {
|
|
|
|
|
continue
|
2026-06-28 08:28:42 +00:00
|
|
|
}
|
2026-07-02 14:52:41 +00:00
|
|
|
key := strings.ToLower(item)
|
|
|
|
|
if _, ok := seen[key]; ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
seen[key] = struct{}{}
|
|
|
|
|
out = append(out, item)
|
|
|
|
|
if len(out) >= 8 {
|
|
|
|
|
break
|
2026-06-28 08:28:42 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-02 14:52:41 +00:00
|
|
|
return out
|
2026-06-28 08:28:42 +00:00
|
|
|
}
|