// Code scaffolded by goctl. Safe to edit. // goctl 1.10.1 package copy_mission import ( "context" "strings" "time" 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" contentops "haixun-backend/internal/model/content_ops/domain/usecase" personadomain "haixun-backend/internal/model/persona/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" "github.com/zeromicro/go-zero/core/logx" ) type InspireCopyMissionLogic struct { logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewInspireCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *InspireCopyMissionLogic { return &InspireCopyMissionLogic{ Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx, } } func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspirationHandlerReq) (resp *types.CopyMissionInspirationData, err error) { tenantID, uid, err := actorFrom(l.ctx) if err != nil { return nil, err } personaID := strings.TrimSpace(req.PersonaID) if personaID == "" { return nil, app.For(code.Persona).InputMissingRequired("persona_id is required") } persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) if err != nil { return nil, err } 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 } 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, }) } 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, })}}, }) if err != nil { return nil, app.For(code.AI).SvcThirdParty("起號靈感生成失敗:" + err.Error()) } parsed, err := libviral.ParseMissionInspireOutput(result.Text) if err != nil { return nil, app.For(code.AI).SvcThirdParty("起號靈感回傳無法解析:" + err.Error()) } 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}, }) if err != nil { l.Errorf("persist topic candidate: %v", err) return "", "" } angle := "" if len(parsed.Angles) > 0 { angle = parsed.Angles[0] } 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 } 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) if err != nil { 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, }) } } } } 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 { sources = append(sources, types.CopyMissionInspirationSourceData{ Query: item.Query, Title: item.Title, Snippet: item.Snippet, URL: item.URL, }) } return sources } func sourceLabel(used bool, sources []types.CopyMissionInspirationSourceData) string { if !used || len(sources) == 0 { return "" } return "Free Trend / Web / Threads Search" } 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 } key := strings.ToLower(item) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, item) if len(out) >= 8 { break } } return out }