365 lines
13 KiB
Go
365 lines
13 KiB
Go
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
|
|
} |