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} } func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspirationHandlerReq) (*types.CopyMissionInspirationData, error) { tenantID, uid, err := actorFrom(l.ctx) if err != nil { return nil, err } personaID := strings.TrimSpace(req.PersonaID) userKeyword := strings.TrimSpace(req.Keyword) 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 := "" trendSourceLabel := "" llmOnly := true research, researchErr := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid) if researchErr == nil { memberCtx, memberErr := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research) if memberErr == nil { 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 } } } webSearchUsed := len(trendSnippets) > 0 && webSearchProvider != "" 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, UserKeyword: userKeyword, TrendSnippets: trendSnippets, WebSearchProvider: trendSourceLabel, 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, }) } message := "已依近期 Threads 熱門素材產出任務靈感,可微調後建立任務" if webSearchUsed { message = "已依近期網路趨勢產出任務靈感,可微調後建立任務" } if len(trendSnippets) == 0 { message = "未取得外部趨勢素材,已改由 LLM 依人設與關鍵字推測靈感;同步 Chrome Session、完成 Threads API 或補上 Brave/Exa key 可更貼近熱搜" } 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 } 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) } }