thread-master/backend/internal/worker/job/expand_copy_mission_graph.go

365 lines
13 KiB
Go
Raw Normal View History

2026-06-28 08:28:42 +00:00
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
}