package job import ( "context" "fmt" "strings" "sync" "haixun-backend/internal/library/clock" 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" "haixun-backend/internal/model/ai/domain/enum" domai "haixun-backend/internal/model/ai/domain/usecase" aiusecase "haixun-backend/internal/model/ai/usecase" brandentity "haixun-backend/internal/model/brand/domain/entity" branddomain "haixun-backend/internal/model/brand/domain/usecase" jobdom "haixun-backend/internal/model/job/domain/usecase" kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase" placementusecase "haixun-backend/internal/model/placement/usecase" topicdomain "haixun-backend/internal/model/placement_topic/domain/usecase" threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" ) type ExpandGraphDeps struct { Jobs jobdom.UseCase Brand branddomain.UseCase PlacementTopic topicdomain.UseCase KnowledgeGraph kgusecase.UseCase ThreadsAccount threadsaccountdomain.UseCase Placement placementusecase.UseCase AI aiusecase.UseCase } func RegisterExpandGraphHandler(runner *Runner, deps ExpandGraphDeps) { if runner == nil { return } runner.RegisterStepHandler("expand", func(ctx context.Context, step StepContext) error { return runExpandGraph(ctx, step, deps) }) } func brandIDFromPayload(payload map[string]any) string { brandID := stringField(payload, "brand_id") if brandID == "" { brandID = stringField(payload, "persona_id") } return brandID } func runExpandGraph(ctx context.Context, step StepContext, deps ExpandGraphDeps) error { payload := step.Run.Payload tenantID := stringField(payload, "tenant_id") ownerUID := stringField(payload, "owner_uid") seed := stringField(payload, "seed_query") supplemental := boolField(payload, "supplemental") if tenantID == "" || ownerUID == "" { return fmt.Errorf("expand-graph payload missing tenant_id or owner_uid") } if seed == "" { return fmt.Errorf("expand-graph payload missing seed_query") } scope, err := resolvePlacementScope(ctx, deps.Brand, deps.PlacementTopic, tenantID, ownerUID, payload) if err != nil { return err } brand := scope.Brand brandID := scope.CatalogBrand if brandID == "" { return fmt.Errorf("expand-graph payload missing brand_id or topic_id") } productBrief := strings.TrimSpace(brand.ProductBrief) if formatted := placement.ProductBriefFromContext(brand.ProductContext); formatted != "" { productBrief = formatted } research, err := deps.Placement.ResearchSettings(ctx, tenantID, ownerUID) if err != nil { return err } 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 } updateProgress := func(summary string, percentage int) { _ = step.Heartbeat(ctx) _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{ JobID: step.JobID, WorkerID: step.WorkerID, Phase: "expand", Summary: summary, Percentage: percentage, }) } bootstrap := boolField(payload, "bootstrap") regenerateMap := boolField(payload, "regenerate_map") expandStrategy := libkg.ParseExpandStrategy(stringField(payload, "expand_strategy")) needResearchMap := bootstrap || regenerateMap || brand.ResearchMap.IsEmpty() prefetchedBrave := []libkg.BraveSource{} var prefetchQueries []string if needResearchMap && expandStrategy.RequiresWebSearch() { updateProgress("平行產生研究地圖與蒐集參考資料…", 5) var mapErr error var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() mapErr = ensureResearchMap(ctx, step, deps, brand, productBrief, providerID, credential, updateProgress) }() go func() { defer wg.Done() prefetchPlan := kgPlanInput(brand, seed, productBrief, nil, false, expandStrategy) prefetchQueries = libkg.PlanBootstrapQueries(prefetchPlan) if len(prefetchQueries) == 0 { return } sources, err := runWebKnowledgeExpand(ctx, webClient, memberCtx, prefetchQueries, expandStrategy, func(i, total int) { pct := 8 + ((i + 1) * 12 / 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 { prefetchedBrave = sources return } prefetchQueries = nil }() wg.Wait() if mapErr != nil { return mapErr } brand, err = reloadScopeBrand(ctx, deps, tenantID, ownerUID, scope) if err != nil { return err } } else if needResearchMap { updateProgress("產生研究地圖…", 5) if err := ensureResearchMap(ctx, step, deps, brand, productBrief, providerID, credential, updateProgress); err != nil { return err } brand, err = reloadScopeBrand(ctx, deps, tenantID, ownerUID, scope) if err != nil { return err } } var existing *kgusecase.GraphSummary if supplemental { existing, _ = deps.KnowledgeGraph.Get(ctx, tenantID, ownerUID, brandID) } braveSources := []libkg.BraveSource{} var systemPrompt string var userPrompt string switch expandStrategy { case libkg.ExpandStrategyLLM: updateProgress("整理延伸知識…", 20) systemPrompt, err = libprompt.KnowledgeGraphLLMSystem() if err != nil { return app.For(code.AI).SysInternal("knowledge graph llm prompt load failed") } topicCtx := placement.PlacementTopicContextFromBrand(brand, productBrief) kgVars := topicCtx.PromptLines() kgVars["seed"] = seed kgVars["product_brief_line"] = libkg.OptionalPromptLine("產品簡述", productBrief) kgVars["target_audience_line"] = libkg.OptionalPromptLine("目標受眾", brand.TargetAudience) kgVars["persona_line"] = libkg.OptionalPromptLine("主題目標", brand.Brief) kgVars["research_pillars_line"] = libkg.BulletPromptLine( "內容支柱(延伸知識要往這些方向廣泛展開)", brand.ResearchMap.Pillars) kgVars["research_questions_line"] = libkg.BulletPromptLine( "受眾提問方向(可衍生成更多周邊節點)", brand.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 := kgPlanInput(brand, seed, productBrief, l1Labels, supplemental, expandStrategy) queries := libkg.PlanQueries(planIn) if len(prefetchedBrave) > 0 { queries = libkg.QueriesExcept(queries, prefetchQueries) } updateProgress(fmt.Sprintf("蒐集參考資料(%d 項查詢)…", len(queries)+len(prefetchQueries)), 25) var moreBrave []libkg.BraveSource if len(queries) > 0 { moreBrave, err = runWebKnowledgeExpand(ctx, webClient, memberCtx, queries, expandStrategy, func(i, total int) { pct := 25 + ((i + 1) * 30 / max(total, 1)) updateProgress(fmt.Sprintf("蒐集參考資料 %d/%d…", len(prefetchQueries)+i+1, len(prefetchQueries)+total), pct) }, func() error { cancelled, _ := deps.Jobs.IsCancelRequested(ctx, step.JobID) if cancelled { return errJobCancelled } return ctx.Err() }) if err != nil { return err } } braveSources = libkg.MergeBraveSources(prefetchedBrave, moreBrave) if len(braveSources) == 0 { return app.For(code.Setting).SvcThirdParty("暫時無法取得參考資料,請稍後重試") } updateProgress("整理延伸知識…", 60) systemPrompt, err = libprompt.KnowledgeGraphSystem() if err != nil { return app.For(code.AI).SysInternal("knowledge graph prompt load failed") } userPrompt, err = libkg.BuildUserPrompt(kgSynthInput(brand, seed, productBrief, 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, ProductBrief: productBrief, TargetAudience: brand.TargetAudience, }, 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, ProductBrief: productBrief, TargetAudience: brand.TargetAudience, }, braveSources); parseErr == nil && !libkg.GraphTooThin(retryGraph) { graph = retryGraph } } } if supplemental && existing != nil { graph = mergeGraphs(existing, graph, braveSources) } needsBreadthExpand := !supplemental && (libkg.GraphNeedsBreadth(graph) || graph.PainTagCount < libkg.MinPainTagCandidates()) if needsBreadthExpand { updateProgress(fmt.Sprintf("擴充延伸知識廣度(目前 %d 節點)…", len(graph.Nodes)), 75) planIn := kgPlanInput(brand, seed, productBrief, libkg.L1LabelsFromNodes(graph.Nodes), true, expandStrategy) if expandStrategy.UsesSupplementalBrave() { suppQueries := libkg.PlanQueries(planIn) extraSources, err := runWebKnowledgeExpand(ctx, webClient, memberCtx, suppQueries, expandStrategy, nil, func() error { cancelled, _ := deps.Jobs.IsCancelRequested(ctx, step.JobID) if cancelled { return errJobCancelled } return ctx.Err() }) if err != nil { return err } braveSources = append(braveSources, extraSources...) } suppInstruction, err := libprompt.KnowledgeGraphSupplemental() if err != nil { return app.For(code.AI).SysInternal("knowledge graph supplemental prompt load failed") } suppUserPrompt, err := libkg.BuildUserPrompt(kgSynthInput(brand, seed, productBrief, braveSources)) if err != nil { return err } breadthPrompt := libkg.KnowledgeGraphBreadthUserPrompt(len(graph.Nodes)) suppResult, err := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{ Provider: providerID, Model: credential.Model, Credential: domai.Credential{ APIKey: credential.APIKey, }, System: systemPrompt, Messages: []domai.Message{ {Role: "user", Content: suppUserPrompt + "\n\n" + suppInstruction + "\n\n" + breadthPrompt}, }, })) if err == nil { if patched, parseErr := libkg.ParseSynthOutput(suppResult.Text, libkg.SynthInput{Seed: seed}, braveSources); parseErr == nil { graph = mergeGraphs(&kgusecase.GraphSummary{ Seed: graph.Seed, Nodes: graph.Nodes, Edges: graph.Edges, }, patched, braveSources) } } } if libkg.GraphNeedsBootstrap(graph) { updateProgress(fmt.Sprintf("從研究地圖補齊延伸知識(目前 %d 節點)…", len(graph.Nodes)), 85) libkg.SupplementGraphFromResearchMap(&graph, seed, brand.ResearchMap.Pillars, brand.ResearchMap.Questions) } patrolInput := libkg.PatrolTagInputFromBrand(brand, productBrief) libkg.DeriveSearchTagsFromGraph(&graph, patrolInput) updateProgress("整理海巡關鍵字…", 88) if err := syncAutoPatrolKeywords(ctx, deps, tenantID, ownerUID, scope, graph.Nodes, productBrief); err != nil { return err } if scope.TopicID != "" { topic, topicErr := deps.PlacementTopic.Get(ctx, tenantID, ownerUID, scope.TopicID) if topicErr == nil && topic != nil { brand = placementTopicAsBrand(scope, topic) } } else { brand, err = deps.Brand.Get(ctx, tenantID, ownerUID, brandID) if err != nil { return err } } updateProgress("儲存研究地圖…", 90) graph.BraveSources = braveSources now := clock.NowUnixNano() saved, err := deps.KnowledgeGraph.Upsert(ctx, kgusecase.UpsertRequest{ TenantID: tenantID, OwnerUID: ownerUID, BrandID: brandID, TopicID: scope.TopicID, 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 := syncResearchMapSources(ctx, deps, tenantID, ownerUID, scope, expandStrategy.String(), braveSources); err != nil { return err } nextRoute := "/research?brand=" + brandID if scope.TopicID != "" { nextRoute = "/placement/topics/" + scope.TopicID + "/research-map" } handoff := map[string]any{ "flow": "placement", "brand_id": brandID, "topic_id": scope.TopicID, "pain_tag_count": saved.PainTagCount, "summary": fmt.Sprintf("圖譜 %d 節點,痛點候選 %d", len(saved.Nodes), saved.PainTagCount), "next_route": nextRoute, "needs_supplemental_expand": saved.PainTagCount < libkg.MinPainTagCandidates() || len(saved.Nodes) < libkg.MinBreadthGraphNodes(), "search_source_mode": string(memberCtx.SearchSourceMode), "dev_mode": memberCtx.DevMode, } _, 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), "search_source_mode": string(memberCtx.SearchSourceMode), "handoff": handoff, }, }) return err } func runWebKnowledgeExpand( ctx context.Context, client websearch.Client, member placement.MemberContext, queries []string, strategy libkg.ExpandStrategy, onProgress func(i, total int), heartbeat func() error, ) ([]libkg.BraveSource, error) { if client == nil || !client.Enabled() { return nil, app.For(code.Setting).InputMissingRequired(placement.WebSearchKeyRequiredMessage(placement.ResearchSettings{ WebSearchProvider: member.WebSearchProvider, BraveAPIKey: member.BraveAPIKey, ExaAPIKey: member.ExaAPIKey, })) } if len(queries) == 0 { if strategy == libkg.ExpandStrategyHybrid { return nil, nil } return nil, app.For(code.Setting).InputMissingRequired("沒有可執行的網路搜尋查詢") } cfg := libkg.DefaultBraveCollectConfig() out := libkg.CollectWebSources(ctx, client, libkg.BraveSearchLocale{ Country: member.BraveCountry, SearchLang: member.BraveSearchLang, UserLocation: member.ExaUserLocation, }, queries, cfg, onProgress, heartbeat) if len(out) == 0 { return nil, app.For(code.Setting).SvcThirdParty("暫時無法取得參考資料,請稍後重試") } return out, nil } func kgPlanInput( brand *branddomain.BrandSummary, seed, productBrief string, l1Labels []string, supplemental bool, strategy libkg.ExpandStrategy, ) libkg.PlanInput { return libkg.PlanInput{ Seed: seed, TargetAudience: brand.TargetAudience, ProductBrief: productBrief, Pillars: brand.ResearchMap.Pillars, Questions: brand.ResearchMap.Questions, PatrolKeywords: brand.ResearchMap.PatrolKeywords, L1Labels: l1Labels, Supplemental: supplemental, Strategy: strategy, } } func kgSynthInput( brand *branddomain.BrandSummary, seed, productBrief string, sources []libkg.BraveSource, ) libkg.SynthInput { topicCtx := placement.PlacementTopicContextFromBrand(brand, productBrief) return libkg.SynthInput{ BrandDisplayName: topicCtx.BrandDisplayName, TopicName: topicCtx.TopicName, ProductLabel: topicCtx.ProductDisplayName(), Goals: topicCtx.Goals, Seed: seed, ProductBrief: productBrief, TargetAudience: brand.TargetAudience, Persona: brand.Brief, ResearchPillars: brand.ResearchMap.Pillars, ResearchQuestions: brand.ResearchMap.Questions, Sources: sources, } } func mergeGraphs(existing *kgusecase.GraphSummary, incoming libkg.Graph, extraSources []libkg.BraveSource) libkg.Graph { if existing == nil { return incoming } merged := libkg.Graph{ Seed: existing.Seed, Nodes: append([]libkg.Node{}, existing.Nodes...), Edges: append([]libkg.Edge{}, existing.Edges...), BraveSources: append([]libkg.BraveSource{}, existing.BraveSources...), } seenLabel := map[string]struct{}{} for _, node := range merged.Nodes { seenLabel[strings.ToLower(strings.TrimSpace(node.Label))] = struct{}{} } for _, node := range incoming.Nodes { key := strings.ToLower(strings.TrimSpace(node.Label)) if _, ok := seenLabel[key]; ok { continue } seenLabel[key] = struct{}{} merged.Nodes = append(merged.Nodes, node) } edgeSeen := map[string]struct{}{} for _, edge := range merged.Edges { edgeSeen[edge.From+"->"+edge.To] = struct{}{} } for _, edge := range incoming.Edges { key := edge.From + "->" + edge.To if _, ok := edgeSeen[key]; ok { continue } edgeSeen[key] = struct{}{} merged.Edges = append(merged.Edges, edge) } merged.BraveSources = append(merged.BraveSources, extraSources...) return merged } func stringField(payload map[string]any, key string) string { if payload == nil { return "" } raw, ok := payload[key] if !ok || raw == nil { return "" } switch v := raw.(type) { case string: return strings.TrimSpace(v) default: return strings.TrimSpace(fmt.Sprint(v)) } } func boolField(payload map[string]any, key string) bool { if payload == nil { return false } raw, ok := payload[key] if !ok || raw == nil { return false } switch v := raw.(type) { case bool: return v case string: return strings.EqualFold(strings.TrimSpace(v), "true") default: return false } } func max(a, b int) int { if a > b { return a } return b } func syncAutoPatrolKeywords( ctx context.Context, deps ExpandGraphDeps, tenantID, ownerUID string, scope *placementScope, nodes []libkg.Node, productBrief string, ) error { if scope == nil || scope.Brand == nil { return nil } fresh := scope.Brand if scope.TopicID != "" { topic, err := deps.PlacementTopic.Get(ctx, tenantID, ownerUID, scope.TopicID) if err != nil { return err } fresh = placementTopicAsBrand(scope, topic) } else { loaded, err := deps.Brand.Get(ctx, tenantID, ownerUID, scope.CatalogBrand) if err != nil { return err } fresh = loaded } patrolInput := libkg.PatrolTagInputFromBrand(fresh, productBrief) tags := libkg.CollectPatrolTagsFromGraph(patrolInput, nodes) if len(tags) == 0 { return nil } entityMap := brandentity.ResearchMap{ AudienceSummary: fresh.ResearchMap.AudienceSummary, ContentGoal: fresh.ResearchMap.ContentGoal, Questions: fresh.ResearchMap.Questions, Pillars: fresh.ResearchMap.Pillars, Exclusions: fresh.ResearchMap.Exclusions, ResearchItems: fresh.ResearchMap.ResearchItems, ExpandStrategy: fresh.ResearchMap.ExpandStrategy, PatrolKeywords: tags, } return updatePlacementResearchMap(ctx, deps, tenantID, ownerUID, scope, entityMap, nil) } func syncResearchMapSources( ctx context.Context, deps ExpandGraphDeps, tenantID, ownerUID string, scope *placementScope, expandStrategy string, sources []libkg.BraveSource, ) error { if expandStrategy == "" || scope == nil || scope.Brand == nil { return nil } items := make([]brandentity.ResearchItem, 0, len(sources)) for _, src := range sources { if strings.TrimSpace(src.URL) == "" && strings.TrimSpace(src.Snippet) == "" { continue } items = append(items, brandentity.ResearchItem{ Title: src.Title, URL: src.URL, Snippet: src.Snippet, Query: src.Query, }) } fresh, err := reloadScopeBrand(ctx, deps, tenantID, ownerUID, scope) if err != nil { return err } if fresh == nil { return nil } entityMap := brandentity.ResearchMap{ AudienceSummary: fresh.ResearchMap.AudienceSummary, ContentGoal: fresh.ResearchMap.ContentGoal, Questions: fresh.ResearchMap.Questions, Pillars: fresh.ResearchMap.Pillars, Exclusions: fresh.ResearchMap.Exclusions, ResearchItems: items, ExpandStrategy: expandStrategy, PatrolKeywords: fresh.ResearchMap.PatrolKeywords, } return updatePlacementResearchMap(ctx, deps, tenantID, ownerUID, scope, entityMap, nil) } func ensureResearchMap( ctx context.Context, step StepContext, deps ExpandGraphDeps, brand *branddomain.BrandSummary, productBrief string, providerID enum.ProviderID, credential *threadsaccountdomain.WorkerAiCredential, updateProgress func(string, int), ) error { tenantID := stringField(step.Run.Payload, "tenant_id") ownerUID := stringField(step.Run.Payload, "owner_uid") if tenantID == "" || ownerUID == "" || brand == nil { return fmt.Errorf("research map: missing actor or brand") } topicCtx := placement.PlacementTopicContextFromBrand(brand, productBrief) mapInput := topicCtx.ToResearchMapInput() updateProgress("分析主題脈絡…", 6) analysisResult, err := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{ Provider: providerID, Model: credential.Model, Credential: domai.Credential{ APIKey: credential.APIKey, }, System: placement.BuildResearchMapAnalysisSystemPrompt(), Messages: []domai.Message{ {Role: "user", Content: placement.BuildResearchMapAnalysisUserPrompt(mapInput)}, }, })) if err != nil { return err } finalPrompt := placement.BuildResearchMapFinalizeUserPrompt(mapInput, analysisResult.Text) updateProgress("產出研究地圖…", 7) result, err := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{ Provider: providerID, Model: credential.Model, Credential: domai.Credential{ APIKey: credential.APIKey, }, System: placement.BuildResearchMapSystemPrompt(), Messages: []domai.Message{ {Role: "user", Content: finalPrompt}, }, })) if err != nil { return err } parsed, err := placement.ParseResearchMapOutput(result.Text) if err != nil { return app.For(code.AI).SvcThirdParty("研究地圖產生失敗,請重試:" + err.Error()) } if placement.ResearchMapTooThin(parsed) { if retryResult, retryErr := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{ Provider: providerID, Model: credential.Model, Credential: domai.Credential{ APIKey: credential.APIKey, }, System: placement.BuildResearchMapSystemPrompt(), Messages: []domai.Message{ {Role: "user", Content: finalPrompt}, {Role: "assistant", Content: result.Text}, {Role: "user", Content: placement.ResearchMapRetryUserPrompt()}, }, })); retryErr == nil { if retryParsed, parseErr := placement.ParseResearchMapOutput(retryResult.Text); parseErr == nil && !placement.ResearchMapTooThin(retryParsed) { parsed = retryParsed } } } entityMap := brandentity.ResearchMap{ AudienceSummary: parsed.AudienceSummary, ContentGoal: parsed.ContentGoal, Questions: parsed.Questions, Pillars: parsed.Pillars, Exclusions: parsed.Exclusions, PatrolKeywords: libkg.SanitizePatrolKeywordList(parsed.PatrolKeywords), } targetAudience := strings.TrimSpace(brand.TargetAudience) if targetAudience == "" { targetAudience = parsed.AudienceSummary } topicID := topicIDFromPayload(step.Run.Payload) scope := &placementScope{ TopicID: topicID, CatalogBrand: brand.ID, Brand: brand, } if err := updatePlacementResearchMap(ctx, deps, tenantID, ownerUID, scope, entityMap, &targetAudience); err != nil { return err } updateProgress("研究地圖已就緒", 8) return nil } func updatePlacementResearchMap( ctx context.Context, deps ExpandGraphDeps, tenantID, ownerUID string, scope *placementScope, entityMap brandentity.ResearchMap, targetAudience *string, ) error { if scope == nil { return nil } if scope.TopicID != "" { patch := topicdomain.TopicPatch{ResearchMap: &entityMap} _, err := deps.PlacementTopic.Update(ctx, topicdomain.UpdateRequest{ TenantID: tenantID, OwnerUID: ownerUID, TopicID: scope.TopicID, Patch: patch, }) return err } patch := branddomain.BrandPatch{ResearchMap: &entityMap} if targetAudience != nil && strings.TrimSpace(*targetAudience) != "" { patch.TargetAudience = targetAudience } _, err := deps.Brand.Update(ctx, branddomain.UpdateRequest{ TenantID: tenantID, OwnerUID: ownerUID, BrandID: scope.CatalogBrand, Patch: patch, }) return err }