2026-06-26 08:37:04 +00:00
|
|
|
|
package copy_mission
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
"haixun-backend/internal/svc"
|
|
|
|
|
|
"haixun-backend/internal/types"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
type InspireCopyMissionLogic struct {
|
|
|
|
|
|
ctx context.Context
|
|
|
|
|
|
svcCtx *svc.ServiceContext
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func NewInspireCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *InspireCopyMissionLogic {
|
|
|
|
|
|
return &InspireCopyMissionLogic{ctx: ctx, svcCtx: svcCtx}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-28 08:28:42 +00:00
|
|
|
|
func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspirationHandlerReq) (*types.CopyMissionInspirationData, 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-06-28 08:28:42 +00:00
|
|
|
|
userKeyword := strings.TrimSpace(req.Keyword)
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
missionList, err := l.svcCtx.CopyMission.List(l.ctx, tenantID, uid, personaID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
recentLabels := make([]string, 0, 8)
|
|
|
|
|
|
recentSeeds := make([]string, 0, 8)
|
|
|
|
|
|
for _, mission := range missionList.List {
|
|
|
|
|
|
if mission.Status == "archived" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if label := strings.TrimSpace(mission.Label); label != "" {
|
|
|
|
|
|
recentLabels = append(recentLabels, label)
|
|
|
|
|
|
}
|
|
|
|
|
|
if seed := strings.TrimSpace(mission.SeedQuery); seed != "" {
|
|
|
|
|
|
recentSeeds = append(recentSeeds, seed)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(recentLabels) >= 8 && len(recentSeeds) >= 8 {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
trendSnippets := []libviral.MissionInspireTrendSnippet{}
|
|
|
|
|
|
webSearchProvider := ""
|
2026-06-28 08:28:42 +00:00
|
|
|
|
trendSourceLabel := ""
|
2026-06-26 08:37:04 +00:00
|
|
|
|
llmOnly := true
|
|
|
|
|
|
|
|
|
|
|
|
research, researchErr := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
|
2026-06-28 08:28:42 +00:00
|
|
|
|
if researchErr == nil {
|
2026-06-26 08:37:04 +00:00
|
|
|
|
memberCtx, memberErr := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
|
|
|
|
|
|
if memberErr == nil {
|
2026-06-28 08:28:42 +00:00
|
|
|
|
trendSnippets = libviral.CollectMissionInspireThreadsTrends(l.ctx, libviral.MissionInspireThreadsInput{
|
|
|
|
|
|
Keyword: userKeyword,
|
|
|
|
|
|
PersonaBrief: persona.Brief,
|
|
|
|
|
|
StyleBenchmark: persona.StyleBenchmark,
|
|
|
|
|
|
Pillars: append([]string(nil), persona.CopyResearchMap.Pillars...),
|
|
|
|
|
|
Questions: append([]string(nil), persona.CopyResearchMap.Questions...),
|
|
|
|
|
|
Member: memberCtx,
|
|
|
|
|
|
Crawler: l.makeCrawlerSearchFn(tenantID, uid),
|
|
|
|
|
|
})
|
|
|
|
|
|
if len(trendSnippets) > 0 {
|
|
|
|
|
|
trendSourceLabel = memberCtx.DiscoverPathLabel()
|
|
|
|
|
|
llmOnly = false
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(trendSnippets) == 0 && placement.WebSearchAvailable(research) {
|
|
|
|
|
|
webClient := websearch.New(memberCtx.WebSearchConfig())
|
|
|
|
|
|
trendSnippets = libviral.CollectMissionInspireTrends(
|
|
|
|
|
|
l.ctx,
|
|
|
|
|
|
webClient,
|
|
|
|
|
|
memberCtx,
|
|
|
|
|
|
persona.Brief+" "+userKeyword,
|
|
|
|
|
|
persona.StyleBenchmark,
|
|
|
|
|
|
)
|
|
|
|
|
|
webSearchProvider = memberCtx.WebSearchProviderLabel()
|
|
|
|
|
|
trendSourceLabel = webSearchProvider
|
|
|
|
|
|
llmOnly = len(trendSnippets) == 0
|
|
|
|
|
|
}
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-28 08:28:42 +00:00
|
|
|
|
webSearchUsed := len(trendSnippets) > 0 && webSearchProvider != ""
|
2026-06-26 08:37:04 +00:00
|
|
|
|
|
|
|
|
|
|
credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief)
|
|
|
|
|
|
result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{
|
|
|
|
|
|
Provider: providerID,
|
|
|
|
|
|
Model: credential.Model,
|
|
|
|
|
|
Credential: domai.Credential{
|
|
|
|
|
|
APIKey: credential.APIKey,
|
|
|
|
|
|
},
|
|
|
|
|
|
System: libviral.BuildMissionInspireSystemPrompt(),
|
|
|
|
|
|
Messages: []domai.Message{
|
|
|
|
|
|
{
|
|
|
|
|
|
Role: "user",
|
|
|
|
|
|
Content: libviral.BuildMissionInspireUserPrompt(libviral.MissionInspireInput{
|
|
|
|
|
|
PersonaDisplayName: persona.DisplayName,
|
|
|
|
|
|
PersonaBrief: persona.Brief,
|
|
|
|
|
|
PersonaBlock: personaBlock,
|
|
|
|
|
|
StyleBenchmark: persona.StyleBenchmark,
|
|
|
|
|
|
PersonaAudience: persona.CopyResearchMap.AudienceSummary,
|
|
|
|
|
|
PersonaContentGoal: persona.CopyResearchMap.ContentGoal,
|
|
|
|
|
|
PersonaQuestions: append([]string(nil), persona.CopyResearchMap.Questions...),
|
|
|
|
|
|
PersonaPillars: append([]string(nil), persona.CopyResearchMap.Pillars...),
|
|
|
|
|
|
RecentMissionLabels: recentLabels,
|
|
|
|
|
|
RecentSeedQueries: recentSeeds,
|
2026-06-28 08:28:42 +00:00
|
|
|
|
UserKeyword: userKeyword,
|
2026-06-26 08:37:04 +00:00
|
|
|
|
TrendSnippets: trendSnippets,
|
2026-06-28 08:28:42 +00:00
|
|
|
|
WebSearchProvider: trendSourceLabel,
|
2026-06-26 08:37:04 +00:00
|
|
|
|
LLMOnly: llmOnly,
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
parsed, err := libviral.ParseMissionInspireOutput(result.Text)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, app.For(code.AI).SvcThirdParty("靈感骰子 LLM 回傳無法解析:" + err.Error())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
sources := make([]types.CopyMissionInspirationSourceData, 0, len(trendSnippets))
|
|
|
|
|
|
for _, item := range trendSnippets {
|
|
|
|
|
|
sources = append(sources, types.CopyMissionInspirationSourceData{
|
|
|
|
|
|
Query: item.Query,
|
|
|
|
|
|
Title: item.Title,
|
|
|
|
|
|
Snippet: item.Snippet,
|
|
|
|
|
|
URL: item.URL,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-28 08:28:42 +00:00
|
|
|
|
message := "已依近期 Threads 熱門素材產出任務靈感,可微調後建立任務"
|
|
|
|
|
|
if webSearchUsed {
|
|
|
|
|
|
message = "已依近期網路趨勢產出任務靈感,可微調後建立任務"
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(trendSnippets) == 0 {
|
|
|
|
|
|
message = "未取得外部趨勢素材,已改由 LLM 依人設與關鍵字推測靈感;同步 Chrome Session、完成 Threads API 或補上 Brave/Exa key 可更貼近熱搜"
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return &types.CopyMissionInspirationData{
|
|
|
|
|
|
Label: parsed.Label,
|
|
|
|
|
|
SeedQuery: parsed.SeedQuery,
|
|
|
|
|
|
Brief: parsed.Brief,
|
|
|
|
|
|
TrendReason: parsed.TrendReason,
|
|
|
|
|
|
TrendKeywords: parsed.TrendKeywords,
|
|
|
|
|
|
Sources: sources,
|
|
|
|
|
|
WebSearchUsed: webSearchUsed,
|
|
|
|
|
|
Message: message,
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
2026-06-28 08:28:42 +00:00
|
|
|
|
|
|
|
|
|
|
func (l *InspireCopyMissionLogic) makeCrawlerSearchFn(tenantID, ownerUID string) placement.CrawlerSearchFn {
|
|
|
|
|
|
return func(ctx context.Context, member placement.MemberContext, keyword string, limit int) ([]placement.DiscoverPost, error) {
|
|
|
|
|
|
accountID := strings.TrimSpace(member.ActiveAccountID)
|
|
|
|
|
|
if accountID == "" {
|
|
|
|
|
|
return nil, app.For(code.Setting).InputMissingRequired("請先選定經營帳號並同步 Chrome Session")
|
|
|
|
|
|
}
|
|
|
|
|
|
session, err := l.svcCtx.ThreadsAccount.GetBrowserSession(ctx, tenantID, ownerUID, accountID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return placement.RunExecCrawlerSearch(ctx, session.StorageState, keyword, limit)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|