package job import ( "context" "fmt" "strings" "haixun-backend/internal/library/clock" libcopy "haixun-backend/internal/library/copymission" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" libkg "haixun-backend/internal/library/knowledge" "haixun-backend/internal/library/placement" libprompt "haixun-backend/internal/library/prompt" "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" kgusecase "haixun-backend/internal/model/knowledge_graph/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 ExpandCopyMissionGraphDeps struct { Jobs jobdom.UseCase CopyMission missiondomain.UseCase Persona personadomain.UseCase KnowledgeGraph kgusecase.UseCase ThreadsAccount threadsaccountdomain.UseCase Placement placementusecase.UseCase AI aiusecase.UseCase } func RegisterExpandCopyMissionGraphHandler(runner *Runner, deps ExpandCopyMissionGraphDeps) { if runner == nil { return } runner.RegisterStepHandler("expand_copy_knowledge", func(ctx context.Context, step StepContext) error { return runExpandCopyMissionGraph(ctx, step, deps) }) } func runExpandCopyMissionGraph(ctx context.Context, step StepContext, deps ExpandCopyMissionGraphDeps) error { payload := step.Run.Payload tenantID, ownerUID := runActorFromPayload(payload, step.Run) personaID := stringField(payload, "persona_id") missionID := copyMissionIDFromPayload(payload) seed := stringField(payload, "seed_query") supplemental := boolField(payload, "supplemental") if tenantID == "" || ownerUID == "" || personaID == "" || missionID == "" { return fmt.Errorf("expand-copy-mission-graph payload missing tenant_id, owner_uid, persona_id, or copy_mission_id") } if seed == "" { return fmt.Errorf("expand-copy-mission-graph payload missing seed_query") } mission, err := deps.CopyMission.Get(ctx, tenantID, ownerUID, personaID, missionID) if err != nil { return err } if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" { return app.For(code.Persona).InputMissingRequired("請先產生研究地圖再擴展延伸知識") } persona, err := deps.Persona.Get(ctx, tenantID, ownerUID, personaID) if err != nil { return err } research, err := deps.Placement.ResearchSettings(ctx, tenantID, ownerUID) if err != nil { return err } expandStrategy := placement.EffectiveExpandStrategy(research) if reqStrategy := strings.TrimSpace(stringField(payload, "expand_strategy")); reqStrategy != "" { expandStrategy = libkg.ParseExpandStrategy(reqStrategy) } if supplemental && placement.WebSearchAvailable(research) { expandStrategy = libkg.ExpandStrategyBrave } memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, ownerUID, research) if err != nil { return err } webClient := websearch.New(memberCtx.WebSearchConfig()) credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, ownerUID) if err != nil { return err } providerID, err := aiusecase.MapWorkerProvider(credential.Provider) if err != nil { return err } entityMap := summaryResearchMap(mission.ResearchMap) missionCtx := libcopy.MissionContext{ Label: mission.Label, SeedQuery: mission.SeedQuery, Brief: mission.Brief, ResearchMap: entityMap, } personaCtx := libcopy.PersonaContext{ DisplayName: persona.DisplayName, Persona: persona.Persona, } updateProgress := func(summary string, percentage int) { _ = step.Heartbeat(ctx) _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{ JobID: step.JobID, WorkerID: step.WorkerID, Phase: "expand_copy_knowledge", Summary: summary, Percentage: percentage, }) } var existing *kgusecase.GraphSummary if supplemental { existing, _ = deps.KnowledgeGraph.GetByCopyMission(ctx, tenantID, ownerUID, missionID) } braveSources := []libkg.BraveSource{} var systemPrompt string var userPrompt string switch expandStrategy { case libkg.ExpandStrategyLLM: updateProgress("整理延伸知識…", 25) systemPrompt, err = libprompt.KnowledgeGraphLLMSystem() if err != nil { return app.For(code.AI).SysInternal("knowledge graph llm prompt load failed") } kgVars := map[string]string{ "seed": seed, "product_brief_line": libkg.OptionalPromptLine("任務補充", mission.Brief), "target_audience_line": libkg.OptionalPromptLine("目標受眾", mission.ResearchMap.AudienceSummary), "persona_line": libkg.OptionalPromptLine("人設", persona.Persona), "research_pillars_line": libkg.BulletPromptLine("內容支柱", mission.ResearchMap.Pillars), "research_questions_line": libkg.BulletPromptLine("受眾提問", mission.ResearchMap.Questions), } userPrompt, err = libprompt.KnowledgeGraphLLMUser(kgVars) if err != nil { return app.For(code.AI).SysInternal("knowledge graph llm user prompt load failed") } default: updateProgress("蒐集參考資料…", 10) l1Labels := []string{} if existing != nil { l1Labels = libkg.L1LabelsFromNodes(existing.Nodes) } planIn := libcopy.PlanInput(missionCtx, seed, l1Labels, supplemental, expandStrategy) queries := libkg.PlanQueries(planIn) updateProgress(fmt.Sprintf("蒐集參考資料(%d 項查詢)…", len(queries)), 25) braveSources, err = runWebKnowledgeExpand(ctx, webClient, memberCtx, queries, expandStrategy, func(i, total int) { pct := 25 + ((i + 1) * 30 / max(total, 1)) updateProgress(fmt.Sprintf("蒐集參考資料 %d/%d…", i+1, total), pct) }, func() error { cancelled, _ := deps.Jobs.IsCancelRequested(ctx, step.JobID) if cancelled { return errJobCancelled } return ctx.Err() }) if err != nil { return err } updateProgress("整理延伸知識…", 60) systemPrompt, err = libprompt.KnowledgeGraphSystem() if err != nil { return app.For(code.AI).SysInternal("knowledge graph prompt load failed") } userPrompt, err = libkg.BuildUserPrompt(libcopy.SynthInput(missionCtx, personaCtx, braveSources)) if err != nil { return app.For(code.AI).SysInternal("knowledge graph user prompt load failed") } } genReq := placement.ResearchGenerateRequest(domai.GenerateRequest{ Provider: providerID, Model: credential.Model, Credential: domai.Credential{ APIKey: credential.APIKey, }, System: systemPrompt, Messages: []domai.Message{ {Role: "user", Content: userPrompt}, }, }) result, err := deps.AI.GenerateText(ctx, genReq) if err != nil { return err } graph, err := libkg.ParseSynthOutput(result.Text, libkg.SynthInput{ Seed: seed, TargetAudience: mission.ResearchMap.AudienceSummary, }, braveSources) if err != nil { return app.For(code.AI).SvcThirdParty("延伸知識產生失敗,請重試:" + err.Error()) } if libkg.GraphTooThin(graph) { retryReq := placement.ResearchGenerateRequest(domai.GenerateRequest{ Provider: providerID, Model: credential.Model, Credential: domai.Credential{ APIKey: credential.APIKey, }, System: systemPrompt, Messages: []domai.Message{ {Role: "user", Content: userPrompt}, {Role: "assistant", Content: result.Text}, {Role: "user", Content: libkg.KnowledgeGraphRetryUserPrompt()}, }, }) if retryResult, retryErr := deps.AI.GenerateText(ctx, retryReq); retryErr == nil { if retryGraph, parseErr := libkg.ParseSynthOutput(retryResult.Text, libkg.SynthInput{ Seed: seed, TargetAudience: mission.ResearchMap.AudienceSummary, }, braveSources); parseErr == nil && !libkg.GraphTooThin(retryGraph) { graph = retryGraph } } } if supplemental && existing != nil { graph = mergeGraphs(existing, graph, braveSources) } if libkg.GraphNeedsBootstrap(graph) { libkg.SupplementGraphFromResearchMap(&graph, seed, mission.ResearchMap.Pillars, mission.ResearchMap.Questions) } preserveSelection := map[string]bool{} if existing != nil { for _, node := range existing.Nodes { preserveSelection[node.ID] = node.SelectedForScan } } libcopy.ApplyDefaultNodeSelectionPreserving(graph.Nodes, preserveSelection) libkg.DeriveSearchTagsFromGraph(&graph, libcopy.PatrolTagInput(missionCtx)) updateProgress("儲存延伸知識…", 90) graph.BraveSources = braveSources now := clock.NowUnixNano() saved, err := deps.KnowledgeGraph.Upsert(ctx, kgusecase.UpsertRequest{ TenantID: tenantID, OwnerUID: ownerUID, BrandID: personaID, CopyMissionID: missionID, Seed: graph.Seed, Nodes: graph.Nodes, Edges: graph.Edges, BraveSources: graph.BraveSources, ExpandStrategy: expandStrategy.String(), PainTagCount: graph.PainTagCount, GeneratedAt: now, }) if err != nil { return err } if err := syncCopyMissionKnowledgeFromGraph(ctx, deps, tenantID, ownerUID, personaID, missionID, entityMap, saved.Nodes, braveSources); err != nil { return err } handoff := map[string]any{ "flow": "copy", "persona_id": personaID, "copy_mission_id": missionID, "summary": fmt.Sprintf("延伸知識 %d 節點,痛點候選 %d", len(saved.Nodes), saved.PainTagCount), "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{ "graph_id": saved.ID, "seed": saved.Seed, "pain_tag_count": saved.PainTagCount, "node_count": len(saved.Nodes), "handoff": handoff, }, }) return err } func summaryResearchMap(summary missiondomain.ResearchMapSummary) missionentity.ResearchMap { tags := make([]missionentity.SuggestedTag, 0, len(summary.SuggestedTags)) for _, tag := range summary.SuggestedTags { tags = append(tags, missionentity.SuggestedTag{ Tag: tag.Tag, Reason: tag.Reason, SearchIntent: tag.SearchIntent, SearchType: tag.SearchType, }) } accounts := make([]missionentity.SimilarAccount, 0, len(summary.SimilarAccounts)) for _, acc := range summary.SimilarAccounts { accounts = append(accounts, missionentity.SimilarAccount{ Username: acc.Username, Reason: acc.Reason, Source: acc.Source, MatchedSource: append([]string(nil), acc.MatchedSource...), Confidence: acc.Confidence, Status: acc.Status, TopicRelevance: acc.TopicRelevance, LastSeenAt: acc.LastSeenAt, ProfileURL: acc.ProfileURL, AuthorVerified: acc.AuthorVerified, FollowerCount: acc.FollowerCount, EngagementScore: acc.EngagementScore, LikeCount: acc.LikeCount, ReplyCount: acc.ReplyCount, PostCount: acc.PostCount, }) } samples := make([]missionentity.AudienceSample, 0, len(summary.AudienceSamples)) for _, sample := range summary.AudienceSamples { samples = append(samples, missionentity.AudienceSample{ Username: sample.Username, SamplePostID: sample.SamplePostID, SampleText: sample.SampleText, ReplyLikeCount: sample.ReplyLikeCount, Appearances: sample.Appearances, FirstSeenAt: sample.FirstSeenAt, LastSeenAt: sample.LastSeenAt, Status: sample.Status, }) } return missionentity.ResearchMap{ AudienceSummary: summary.AudienceSummary, ContentGoal: summary.ContentGoal, Questions: append([]string(nil), summary.Questions...), Pillars: append([]string(nil), summary.Pillars...), Exclusions: append([]string(nil), summary.Exclusions...), KnowledgeItems: append([]string(nil), summary.KnowledgeItems...), SelectedKnowledgeItems: append([]string(nil), summary.SelectedKnowledgeItems...), SuggestedTags: tags, SimilarAccounts: accounts, AudienceSamples: samples, BenchmarkNotes: summary.BenchmarkNotes, } } func syncCopyMissionKnowledgeFromGraph( ctx context.Context, deps ExpandCopyMissionGraphDeps, tenantID, ownerUID, personaID, missionID string, prev missionentity.ResearchMap, nodes []libkg.Node, sources []libkg.BraveSource, ) error { researchMap := libcopy.BuildResearchMapFromGraph(prev, nodes, sources) _, err := deps.CopyMission.Update(ctx, missiondomain.UpdateRequest{ TenantID: tenantID, OwnerUID: ownerUID, PersonaID: personaID, MissionID: missionID, Patch: missiondomain.MissionPatch{ ResearchMap: &researchMap, }, }) return err }