haixunMaster/haixun-backend/internal/worker/job/expand_graph.go

807 lines
25 KiB
Go
Raw Normal View History

2026-06-24 10:02:42 +00:00
package job
import (
"context"
"fmt"
"strings"
2026-06-24 16:48:56 +00:00
"sync"
2026-06-24 10:02:42 +00:00
"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"
2026-06-25 08:20:03 +00:00
"haixun-backend/internal/library/websearch"
2026-06-24 10:02:42 +00:00
"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"
2026-06-24 16:48:56 +00:00
topicdomain "haixun-backend/internal/model/placement_topic/domain/usecase"
2026-06-24 10:02:42 +00:00
threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
)
type ExpandGraphDeps struct {
Jobs jobdom.UseCase
Brand branddomain.UseCase
2026-06-24 16:48:56 +00:00
PlacementTopic topicdomain.UseCase
2026-06-24 10:02:42 +00:00
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")
2026-06-24 16:48:56 +00:00
if tenantID == "" || ownerUID == "" {
return fmt.Errorf("expand-graph payload missing tenant_id or owner_uid")
2026-06-24 10:02:42 +00:00
}
if seed == "" {
return fmt.Errorf("expand-graph payload missing seed_query")
}
2026-06-24 16:48:56 +00:00
scope, err := resolvePlacementScope(ctx, deps.Brand, deps.PlacementTopic, tenantID, ownerUID, payload)
2026-06-24 10:02:42 +00:00
if err != nil {
return err
}
2026-06-24 16:48:56 +00:00
brand := scope.Brand
brandID := scope.CatalogBrand
if brandID == "" {
return fmt.Errorf("expand-graph payload missing brand_id or topic_id")
}
2026-06-24 10:02:42 +00:00
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
}
2026-06-25 08:20:03 +00:00
webClient := websearch.New(memberCtx.WebSearchConfig())
2026-06-24 10:02:42 +00:00
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")
2026-06-24 16:48:56 +00:00
regenerateMap := boolField(payload, "regenerate_map")
expandStrategy := libkg.ParseExpandStrategy(stringField(payload, "expand_strategy"))
needResearchMap := bootstrap || regenerateMap || brand.ResearchMap.IsEmpty()
prefetchedBrave := []libkg.BraveSource{}
var prefetchQueries []string
2026-06-25 08:20:03 +00:00
if needResearchMap && expandStrategy.RequiresWebSearch() {
2026-06-24 16:48:56 +00:00
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
}
2026-06-25 08:20:03 +00:00
sources, err := runWebKnowledgeExpand(ctx, webClient, memberCtx, prefetchQueries, expandStrategy, func(i, total int) {
2026-06-24 16:48:56 +00:00
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
}
2026-06-25 08:20:03 +00:00
brand, err = reloadScopeBrand(ctx, deps, tenantID, ownerUID, scope)
2026-06-24 16:48:56 +00:00
if err != nil {
return err
}
} else if needResearchMap {
2026-06-24 10:02:42 +00:00
updateProgress("產生研究地圖…", 5)
if err := ensureResearchMap(ctx, step, deps, brand, productBrief, providerID, credential, updateProgress); err != nil {
return err
}
2026-06-25 08:20:03 +00:00
brand, err = reloadScopeBrand(ctx, deps, tenantID, ownerUID, scope)
2026-06-24 10:02:42 +00:00
if err != nil {
return err
}
}
var existing *kgusecase.GraphSummary
if supplemental {
existing, _ = deps.KnowledgeGraph.Get(ctx, tenantID, ownerUID, brandID)
}
2026-06-24 16:48:56 +00:00
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)
2026-06-24 10:02:42 +00:00
2026-06-24 16:48:56 +00:00
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)
}
2026-06-24 10:02:42 +00:00
2026-06-24 16:48:56 +00:00
updateProgress(fmt.Sprintf("蒐集參考資料(%d 項查詢)…", len(queries)+len(prefetchQueries)), 25)
var moreBrave []libkg.BraveSource
if len(queries) > 0 {
2026-06-25 08:20:03 +00:00
moreBrave, err = runWebKnowledgeExpand(ctx, webClient, memberCtx, queries, expandStrategy, func(i, total int) {
2026-06-24 16:48:56 +00:00
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("暫時無法取得參考資料,請稍後重試")
2026-06-24 10:02:42 +00:00
}
2026-06-24 16:48:56 +00:00
updateProgress("整理延伸知識…", 60)
2026-06-24 10:02:42 +00:00
2026-06-24 16:48:56 +00:00
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")
}
2026-06-24 10:02:42 +00:00
}
2026-06-24 16:48:56 +00:00
genReq := placement.ResearchGenerateRequest(domai.GenerateRequest{
2026-06-24 10:02:42 +00:00
Provider: providerID,
Model: credential.Model,
Credential: domai.Credential{
APIKey: credential.APIKey,
},
System: systemPrompt,
Messages: []domai.Message{
{Role: "user", Content: userPrompt},
},
})
2026-06-24 16:48:56 +00:00
result, err := deps.AI.GenerateText(ctx, genReq)
2026-06-24 10:02:42 +00:00
if err != nil {
return err
}
graph, err := libkg.ParseSynthOutput(result.Text, libkg.SynthInput{
Seed: seed,
ProductBrief: productBrief,
TargetAudience: brand.TargetAudience,
}, braveSources)
if err != nil {
2026-06-24 16:48:56 +00:00
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
}
}
2026-06-24 10:02:42 +00:00
}
if supplemental && existing != nil {
graph = mergeGraphs(existing, graph, braveSources)
}
2026-06-24 16:48:56 +00:00
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)
2026-06-25 08:20:03 +00:00
extraSources, err := runWebKnowledgeExpand(ctx, webClient, memberCtx, suppQueries, expandStrategy, nil, func() error {
2026-06-24 16:48:56 +00:00
cancelled, _ := deps.Jobs.IsCancelRequested(ctx, step.JobID)
if cancelled {
return errJobCancelled
}
return ctx.Err()
})
if err != nil {
return err
2026-06-24 10:02:42 +00:00
}
2026-06-24 16:48:56 +00:00
braveSources = append(braveSources, extraSources...)
2026-06-24 10:02:42 +00:00
}
suppInstruction, err := libprompt.KnowledgeGraphSupplemental()
if err != nil {
return app.For(code.AI).SysInternal("knowledge graph supplemental prompt load failed")
}
2026-06-24 16:48:56 +00:00
suppUserPrompt, err := libkg.BuildUserPrompt(kgSynthInput(brand, seed, productBrief, braveSources))
2026-06-24 10:02:42 +00:00
if err != nil {
return err
}
2026-06-24 16:48:56 +00:00
breadthPrompt := libkg.KnowledgeGraphBreadthUserPrompt(len(graph.Nodes))
suppResult, err := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{
2026-06-24 10:02:42 +00:00
Provider: providerID,
Model: credential.Model,
Credential: domai.Credential{
APIKey: credential.APIKey,
},
System: systemPrompt,
Messages: []domai.Message{
2026-06-24 16:48:56 +00:00
{Role: "user", Content: suppUserPrompt + "\n\n" + suppInstruction + "\n\n" + breadthPrompt},
2026-06-24 10:02:42 +00:00
},
2026-06-24 16:48:56 +00:00
}))
2026-06-24 10:02:42 +00:00
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)
}
}
}
2026-06-24 16:48:56 +00:00
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)
2026-06-24 10:02:42 +00:00
graph.BraveSources = braveSources
now := clock.NowUnixNano()
saved, err := deps.KnowledgeGraph.Upsert(ctx, kgusecase.UpsertRequest{
2026-06-24 16:48:56 +00:00
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,
2026-06-24 10:02:42 +00:00
})
if err != nil {
return err
}
2026-06-24 16:48:56 +00:00
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"
}
2026-06-24 10:02:42 +00:00
handoff := map[string]any{
"flow": "placement",
"brand_id": brandID,
2026-06-24 16:48:56 +00:00
"topic_id": scope.TopicID,
2026-06-24 10:02:42 +00:00
"pain_tag_count": saved.PainTagCount,
"summary": fmt.Sprintf("圖譜 %d 節點,痛點候選 %d", len(saved.Nodes), saved.PainTagCount),
2026-06-24 16:48:56 +00:00
"next_route": nextRoute,
"needs_supplemental_expand": saved.PainTagCount < libkg.MinPainTagCandidates() || len(saved.Nodes) < libkg.MinBreadthGraphNodes(),
2026-06-24 10:02:42 +00:00
"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
}
2026-06-25 08:20:03 +00:00
func runWebKnowledgeExpand(
2026-06-24 10:02:42 +00:00
ctx context.Context,
2026-06-25 08:20:03 +00:00
client websearch.Client,
2026-06-24 10:02:42 +00:00
member placement.MemberContext,
queries []string,
2026-06-24 16:48:56 +00:00
strategy libkg.ExpandStrategy,
2026-06-24 10:02:42 +00:00
onProgress func(i, total int),
heartbeat func() error,
) ([]libkg.BraveSource, error) {
if client == nil || !client.Enabled() {
2026-06-25 08:20:03 +00:00
return nil, app.For(code.Setting).InputMissingRequired(placement.WebSearchKeyRequiredMessage(placement.ResearchSettings{
WebSearchProvider: member.WebSearchProvider,
BraveAPIKey: member.BraveAPIKey,
ExaAPIKey: member.ExaAPIKey,
}))
2026-06-24 10:02:42 +00:00
}
2026-06-24 16:48:56 +00:00
if len(queries) == 0 {
if strategy == libkg.ExpandStrategyHybrid {
return nil, nil
2026-06-24 10:02:42 +00:00
}
2026-06-25 08:20:03 +00:00
return nil, app.For(code.Setting).InputMissingRequired("沒有可執行的網路搜尋查詢")
2026-06-24 10:02:42 +00:00
}
2026-06-24 16:48:56 +00:00
cfg := libkg.DefaultBraveCollectConfig()
2026-06-25 08:20:03 +00:00
out := libkg.CollectWebSources(ctx, client, libkg.BraveSearchLocale{
Country: member.BraveCountry,
SearchLang: member.BraveSearchLang,
UserLocation: member.ExaUserLocation,
2026-06-24 16:48:56 +00:00
}, queries, cfg, onProgress, heartbeat)
2026-06-24 10:02:42 +00:00
if len(out) == 0 {
2026-06-24 16:48:56 +00:00
return nil, app.For(code.Setting).SvcThirdParty("暫時無法取得參考資料,請稍後重試")
2026-06-24 10:02:42 +00:00
}
return out, nil
}
2026-06-24 16:48:56 +00:00
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,
}
}
2026-06-24 10:02:42 +00:00
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
}
2026-06-24 16:48:56 +00:00
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,
})
}
2026-06-25 08:20:03 +00:00
fresh, err := reloadScopeBrand(ctx, deps, tenantID, ownerUID, scope)
if err != nil {
return err
}
if fresh == nil {
return nil
}
2026-06-24 16:48:56 +00:00
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)
}
2026-06-24 10:02:42 +00:00
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")
}
2026-06-24 16:48:56 +00:00
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{
2026-06-24 10:02:42 +00:00
Provider: providerID,
Model: credential.Model,
Credential: domai.Credential{
APIKey: credential.APIKey,
},
System: placement.BuildResearchMapSystemPrompt(),
Messages: []domai.Message{
2026-06-24 16:48:56 +00:00
{Role: "user", Content: finalPrompt},
2026-06-24 10:02:42 +00:00
},
2026-06-24 16:48:56 +00:00
}))
2026-06-24 10:02:42 +00:00
if err != nil {
return err
}
parsed, err := placement.ParseResearchMapOutput(result.Text)
if err != nil {
2026-06-24 16:48:56 +00:00
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
}
}
2026-06-24 10:02:42 +00:00
}
entityMap := brandentity.ResearchMap{
AudienceSummary: parsed.AudienceSummary,
ContentGoal: parsed.ContentGoal,
Questions: parsed.Questions,
Pillars: parsed.Pillars,
Exclusions: parsed.Exclusions,
2026-06-24 16:48:56 +00:00
PatrolKeywords: libkg.SanitizePatrolKeywordList(parsed.PatrolKeywords),
2026-06-24 10:02:42 +00:00
}
targetAudience := strings.TrimSpace(brand.TargetAudience)
if targetAudience == "" {
targetAudience = parsed.AudienceSummary
}
2026-06-24 16:48:56 +00:00
topicID := topicIDFromPayload(step.Run.Payload)
scope := &placementScope{
TopicID: topicID,
CatalogBrand: brand.ID,
Brand: brand,
2026-06-24 10:02:42 +00:00
}
2026-06-24 16:48:56 +00:00
if err := updatePlacementResearchMap(ctx, deps, tenantID, ownerUID, scope, entityMap, &targetAudience); err != nil {
return err
2026-06-24 10:02:42 +00:00
}
2026-06-24 16:48:56 +00:00
updateProgress("研究地圖已就緒", 8)
return nil
}
2026-06-24 10:02:42 +00:00
2026-06-24 16:48:56 +00:00
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{
2026-06-24 10:02:42 +00:00
TenantID: tenantID,
OwnerUID: ownerUID,
2026-06-24 16:48:56 +00:00
BrandID: scope.CatalogBrand,
2026-06-24 10:02:42 +00:00
Patch: patch,
})
2026-06-24 16:48:56 +00:00
return err
2026-06-24 10:02:42 +00:00
}