807 lines
25 KiB
Go
807 lines
25 KiB
Go
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
|
|
}
|