package job import ( "context" "fmt" "strings" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" "haixun-backend/internal/library/placement" 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" missionentity "haixun-backend/internal/model/copy_mission/domain/entity" missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" jobdom "haixun-backend/internal/model/job/domain/usecase" personadomain "haixun-backend/internal/model/persona/domain/usecase" placementusecase "haixun-backend/internal/model/placement/usecase" threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" ) type AnalyzeCopyMissionDeps struct { Jobs jobdom.UseCase CopyMission missiondomain.UseCase Persona personadomain.UseCase ThreadsAccount threadsaccountdomain.UseCase Placement placementusecase.UseCase AI aiusecase.UseCase } func RegisterAnalyzeCopyMissionHandler(runner *Runner, deps AnalyzeCopyMissionDeps) { if runner == nil { return } runner.RegisterStepHandler("copy_mission_map", func(ctx context.Context, step StepContext) error { return runAnalyzeCopyMission(ctx, step, deps) }) } func runAnalyzeCopyMission(ctx context.Context, step StepContext, deps AnalyzeCopyMissionDeps) error { payload := step.Run.Payload tenantID, ownerUID := runActorFromPayload(payload, step.Run) personaID := stringField(payload, "persona_id") missionID := stringField(payload, "copy_mission_id") if tenantID == "" || ownerUID == "" || personaID == "" || missionID == "" { return fmt.Errorf("analyze-copy-mission payload missing tenant_id, owner_uid, persona_id, or copy_mission_id") } mission, err := deps.CopyMission.Get(ctx, tenantID, ownerUID, personaID, missionID) if err != nil { return err } persona, err := deps.Persona.Get(ctx, tenantID, ownerUID, personaID) if err != nil { return err } updateProgress := func(summary string, percentage int) { _ = step.Heartbeat(ctx) _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{ JobID: step.JobID, WorkerID: step.WorkerID, Phase: "copy_mission_map", Summary: summary, Percentage: percentage, }) } updateProgress("產生拷貝任務研究地圖…", 15) credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, ownerUID) if err != nil { return err } providerID, err := aiusecase.MapWorkerProvider(credential.Provider) if err != nil { return err } result, err := deps.AI.GenerateText(ctx, domai.GenerateRequest{ Provider: providerID, Model: credential.Model, Credential: domai.Credential{ APIKey: credential.APIKey, }, System: libviral.BuildMissionResearchMapSystemPrompt(), Messages: []domai.Message{ { Role: "user", Content: libviral.BuildMissionResearchMapUserPrompt(libviral.CopyResearchMapInput{ Label: mission.Label, SeedQuery: mission.SeedQuery, Brief: mission.Brief, Persona: persona.Persona, StyleBenchmark: persona.StyleBenchmark, PersonaAudienceSummary: persona.CopyResearchMap.AudienceSummary, PersonaContentGoal: persona.CopyResearchMap.ContentGoal, PersonaQuestions: append([]string(nil), persona.CopyResearchMap.Questions...), PersonaPillars: append([]string(nil), persona.CopyResearchMap.Pillars...), }), }, }, }) if err != nil { return err } parsed, err := libviral.ParseMissionResearchMapOutput(result.Text) if err != nil { return app.For(code.AI).SvcThirdParty("拷貝任務研究地圖 LLM 回傳無法解析:" + err.Error()) } entityTags := make([]missionentity.SuggestedTag, 0, len(parsed.SuggestedTags)) for _, tag := range parsed.SuggestedTags { entityTags = append(entityTags, missionentity.SuggestedTag{ Tag: tag.Tag, Reason: tag.Reason, SearchIntent: tag.SearchIntent, SearchType: tag.SearchType, }) } researchMap := missionentity.ResearchMap{ AudienceSummary: parsed.AudienceSummary, ContentGoal: parsed.ContentGoal, Questions: parsed.Questions, Pillars: parsed.Pillars, Exclusions: parsed.Exclusions, KnowledgeItems: baseKnowledgeItems(parsed), BenchmarkNotes: parsed.BenchmarkNotes, SimilarAccounts: similarAccountsFromSummary(mission.ResearchMap.SimilarAccounts), } researchMap.SuggestedTags = entityTags updateProgress("補充相似帳號與延伸知識…", 70) enrichMissionResearchMap(ctx, deps, tenantID, ownerUID, mission, &researchMap) if len(researchMap.SelectedKnowledgeItems) == 0 { researchMap.SelectedKnowledgeItems = defaultSelectedKnowledgeItems(researchMap.KnowledgeItems) } selected := libviral.PickDefaultSelectedTags(parsed.SuggestedTags) selected = ensureSeedQuerySelected(mission.SeedQuery, selected) mapped := missionentity.StatusMapped updateProgress("儲存研究地圖與預設標籤…", 85) _, err = deps.CopyMission.Update(ctx, missiondomain.UpdateRequest{ TenantID: tenantID, OwnerUID: ownerUID, PersonaID: personaID, MissionID: missionID, Patch: missiondomain.MissionPatch{ ResearchMap: &researchMap, SelectedTagsSet: true, SelectedTags: selected, Status: &mapped, }, }) if err != nil { return err } handoff := map[string]any{ "flow": "copy", "persona_id": personaID, "copy_mission_id": missionID, "summary": fmt.Sprintf("研究地圖就緒,已預選 %d 個搜尋標籤", len(selected)), "next_route": fmt.Sprintf("/matrix/missions/%s", missionID), } _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{ JobID: step.JobID, WorkerID: step.WorkerID, Result: map[string]any{ "tag_count": len(entityTags), "account_count": 0, "selected_count": len(selected), "handoff": handoff, }, }) return err } func enrichMissionResearchMap( ctx context.Context, deps AnalyzeCopyMissionDeps, tenantID, ownerUID string, mission *missiondomain.MissionSummary, researchMap *missionentity.ResearchMap, ) { if mission == nil || researchMap == nil || deps.Placement == nil || deps.ThreadsAccount == nil { return } settings, err := deps.Placement.ResearchSettings(ctx, tenantID, ownerUID) if err != nil || !placement.WebSearchAvailable(settings) { return } memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, ownerUID, settings) if err != nil || memberCtx.WebSearchAPIKey() == "" { return } client := websearch.New(memberCtx.WebSearchConfig()) if !client.Enabled() { return } if len(researchMap.SimilarAccounts) < libviral.MaxSimilarAccounts { webAccounts, err := libviral.DiscoverSimilarAccounts(ctx, client, libviral.DiscoverAccountsInput{ SeedQuery: mission.SeedQuery, Brief: mission.Brief, Pillars: researchMap.Pillars, }) if err == nil && len(webAccounts) > 0 { researchMap.SimilarAccounts = libviral.MergeSimilarAccounts( researchMap.SimilarAccounts, similarAccountEntitiesFromWeb(webAccounts), ) } } if notes := collectMissionKnowledgeNotes(ctx, client, memberCtx, mission, researchMap); len(notes) > 0 { researchMap.KnowledgeItems = mergeStringLists(researchMap.KnowledgeItems, notes) } } func similarAccountEntitiesFromWeb(in []libviral.SimilarAccount) []missionentity.SimilarAccount { out := make([]missionentity.SimilarAccount, 0, len(in)) for _, item := range in { out = append(out, missionentity.SimilarAccount{ Username: item.Username, Reason: item.Reason, Source: item.Source, MatchedSource: item.MatchedSource, Confidence: item.Confidence, Status: missionentity.SimilarAccountStatusRecommended, ProfileURL: item.ProfileURL, }) } return out } func collectMissionKnowledgeNotes( ctx context.Context, client websearch.Client, member placement.MemberContext, mission *missiondomain.MissionSummary, researchMap *missionentity.ResearchMap, ) []string { seed := strings.TrimSpace(mission.SeedQuery) if seed == "" { return nil } queries := []string{seed + " 重點 懶人包"} if len(researchMap.Questions) > 0 { if q := strings.TrimSpace(researchMap.Questions[0]); q != "" && q != seed { queries = append(queries, q) } } seen := map[string]struct{}{} lines := []string{} for _, query := range queries { if len(lines) >= 5 { break } res, err := client.Search(ctx, websearch.SearchOptions{ Query: query, Limit: 4, Mode: websearch.ModeKnowledgeExpand, Country: member.BraveCountry, SearchLang: member.BraveSearchLang, UserLocation: member.ExaUserLocation, }) if err != nil || res.Status != "success" { continue } for _, item := range res.Results { if len(lines) >= 5 { break } title := strings.TrimSpace(item.Title) snippet := strings.TrimSpace(item.Snippet) if title == "" && snippet == "" { continue } key := strings.ToLower(title + "|" + snippet) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} line := title if snippet != "" { if len([]rune(snippet)) > 120 { snippet = string([]rune(snippet)[:120]) } line += ":" + snippet } lines = append(lines, line) } } if len(lines) == 0 { return nil } return lines } func baseKnowledgeItems(parsed libviral.MissionResearchMap) []string { items := []string{} for _, pillar := range parsed.Pillars { if strings.TrimSpace(pillar) != "" { items = append(items, "內容支柱:"+strings.TrimSpace(pillar)) } } for _, question := range parsed.Questions { if strings.TrimSpace(question) != "" { items = append(items, "受眾問題:"+strings.TrimSpace(question)) } } if strings.TrimSpace(parsed.BenchmarkNotes) != "" { items = append(items, "研究筆記:"+strings.TrimSpace(parsed.BenchmarkNotes)) } return mergeStringLists(nil, items) } func defaultSelectedKnowledgeItems(items []string) []string { if len(items) == 0 { return nil } if len(items) > 6 { return append([]string(nil), items[:6]...) } return append([]string(nil), items...) } func mergeStringLists(base []string, extra []string) []string { seen := map[string]struct{}{} out := make([]string, 0, len(base)+len(extra)) for _, item := range append(append([]string{}, base...), extra...) { item = strings.TrimSpace(item) if item == "" { continue } if _, ok := seen[item]; ok { continue } seen[item] = struct{}{} out = append(out, item) } return out } func ensureSeedQuerySelected(seed string, selected []string) []string { seed = strings.TrimSpace(seed) if seed == "" { return selected } for _, item := range selected { if strings.EqualFold(strings.TrimSpace(item), seed) { return selected } } out := append([]string{seed}, selected...) if len(out) > libviral.MaxScanTags { out = out[:libviral.MaxScanTags] } return out } func copyMissionIDFromPayload(payload map[string]any) string { if id := stringField(payload, "copy_mission_id"); id != "" { return id } return strings.TrimSpace(stringField(payload, "mission_id")) }