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

369 lines
11 KiB
Go
Raw Normal View History

2026-06-26 08:37:04 +00:00
package job
import (
"context"
"fmt"
"strings"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
2026-06-28 08:28:42 +00:00
"haixun-backend/internal/library/placement"
2026-06-26 08:37:04 +00:00
libviral "haixun-backend/internal/library/viral"
2026-06-28 08:28:42 +00:00
"haixun-backend/internal/library/websearch"
2026-06-26 08:37:04 +00:00
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"
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 AnalyzeCopyMissionDeps struct {
Jobs jobdom.UseCase
CopyMission missiondomain.UseCase
Persona personadomain.UseCase
ThreadsAccount threadsaccountdomain.UseCase
Placement placementusecase.UseCase
AI aiusecase.UseCase
}
func RegisterAnalyzeCopyMissionHandler(runner *Runner, deps AnalyzeCopyMissionDeps) {
if runner == nil {
return
}
runner.RegisterStepHandler("copy_mission_map", func(ctx context.Context, step StepContext) error {
return runAnalyzeCopyMission(ctx, step, deps)
})
}
func runAnalyzeCopyMission(ctx context.Context, step StepContext, deps AnalyzeCopyMissionDeps) error {
payload := step.Run.Payload
tenantID, ownerUID := runActorFromPayload(payload, step.Run)
personaID := stringField(payload, "persona_id")
missionID := stringField(payload, "copy_mission_id")
if tenantID == "" || ownerUID == "" || personaID == "" || missionID == "" {
return fmt.Errorf("analyze-copy-mission payload missing tenant_id, owner_uid, persona_id, or copy_mission_id")
}
mission, err := deps.CopyMission.Get(ctx, tenantID, ownerUID, personaID, missionID)
if err != nil {
return err
}
persona, err := deps.Persona.Get(ctx, tenantID, ownerUID, personaID)
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: "copy_mission_map",
Summary: summary,
Percentage: percentage,
})
}
updateProgress("產生拷貝任務研究地圖…", 15)
credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, ownerUID)
if err != nil {
return err
}
providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
if err != nil {
return err
}
result, err := deps.AI.GenerateText(ctx, domai.GenerateRequest{
Provider: providerID,
Model: credential.Model,
Credential: domai.Credential{
APIKey: credential.APIKey,
},
System: libviral.BuildMissionResearchMapSystemPrompt(),
Messages: []domai.Message{
{
Role: "user",
Content: libviral.BuildMissionResearchMapUserPrompt(libviral.CopyResearchMapInput{
Label: mission.Label,
SeedQuery: mission.SeedQuery,
Brief: mission.Brief,
Persona: persona.Persona,
StyleBenchmark: persona.StyleBenchmark,
PersonaAudienceSummary: persona.CopyResearchMap.AudienceSummary,
PersonaContentGoal: persona.CopyResearchMap.ContentGoal,
PersonaQuestions: append([]string(nil), persona.CopyResearchMap.Questions...),
PersonaPillars: append([]string(nil), persona.CopyResearchMap.Pillars...),
}),
},
},
})
if err != nil {
return err
}
parsed, err := libviral.ParseMissionResearchMapOutput(result.Text)
if err != nil {
return app.For(code.AI).SvcThirdParty("拷貝任務研究地圖 LLM 回傳無法解析:" + err.Error())
}
entityTags := make([]missionentity.SuggestedTag, 0, len(parsed.SuggestedTags))
for _, tag := range parsed.SuggestedTags {
entityTags = append(entityTags, missionentity.SuggestedTag{
Tag: tag.Tag,
Reason: tag.Reason,
SearchIntent: tag.SearchIntent,
SearchType: tag.SearchType,
})
}
researchMap := missionentity.ResearchMap{
AudienceSummary: parsed.AudienceSummary,
ContentGoal: parsed.ContentGoal,
Questions: parsed.Questions,
Pillars: parsed.Pillars,
Exclusions: parsed.Exclusions,
2026-06-28 08:28:42 +00:00
KnowledgeItems: baseKnowledgeItems(parsed),
2026-06-26 08:37:04 +00:00
BenchmarkNotes: parsed.BenchmarkNotes,
2026-06-28 08:28:42 +00:00
SimilarAccounts: similarAccountsFromSummary(mission.ResearchMap.SimilarAccounts),
2026-06-26 08:37:04 +00:00
}
researchMap.SuggestedTags = entityTags
2026-06-28 08:28:42 +00:00
updateProgress("補充相似帳號與延伸知識…", 70)
enrichMissionResearchMap(ctx, deps, tenantID, ownerUID, mission, &researchMap)
if len(researchMap.SelectedKnowledgeItems) == 0 {
researchMap.SelectedKnowledgeItems = defaultSelectedKnowledgeItems(researchMap.KnowledgeItems)
}
2026-06-26 08:37:04 +00:00
selected := libviral.PickDefaultSelectedTags(parsed.SuggestedTags)
2026-06-28 08:28:42 +00:00
selected = ensureSeedQuerySelected(mission.SeedQuery, selected)
2026-06-26 08:37:04 +00:00
mapped := missionentity.StatusMapped
updateProgress("儲存研究地圖與預設標籤…", 85)
_, err = deps.CopyMission.Update(ctx, missiondomain.UpdateRequest{
TenantID: tenantID,
OwnerUID: ownerUID,
PersonaID: personaID,
MissionID: missionID,
Patch: missiondomain.MissionPatch{
ResearchMap: &researchMap,
SelectedTagsSet: true,
SelectedTags: selected,
Status: &mapped,
},
})
if err != nil {
return err
}
handoff := map[string]any{
"flow": "copy",
"persona_id": personaID,
"copy_mission_id": missionID,
"summary": fmt.Sprintf("研究地圖就緒,已預選 %d 個搜尋標籤", len(selected)),
"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{
"tag_count": len(entityTags),
"account_count": 0,
"selected_count": len(selected),
"handoff": handoff,
},
})
return err
}
2026-06-28 08:28:42 +00:00
func enrichMissionResearchMap(
ctx context.Context,
deps AnalyzeCopyMissionDeps,
tenantID, ownerUID string,
mission *missiondomain.MissionSummary,
researchMap *missionentity.ResearchMap,
) {
if mission == nil || researchMap == nil || deps.Placement == nil || deps.ThreadsAccount == nil {
return
}
settings, err := deps.Placement.ResearchSettings(ctx, tenantID, ownerUID)
if err != nil || !placement.WebSearchAvailable(settings) {
return
}
memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, ownerUID, settings)
if err != nil || memberCtx.WebSearchAPIKey() == "" {
return
}
client := websearch.New(memberCtx.WebSearchConfig())
if !client.Enabled() {
return
}
if len(researchMap.SimilarAccounts) < libviral.MaxSimilarAccounts {
webAccounts, err := libviral.DiscoverSimilarAccounts(ctx, client, libviral.DiscoverAccountsInput{
SeedQuery: mission.SeedQuery,
Brief: mission.Brief,
Pillars: researchMap.Pillars,
})
if err == nil && len(webAccounts) > 0 {
researchMap.SimilarAccounts = libviral.MergeSimilarAccounts(
researchMap.SimilarAccounts,
similarAccountEntitiesFromWeb(webAccounts),
)
}
}
if notes := collectMissionKnowledgeNotes(ctx, client, memberCtx, mission, researchMap); len(notes) > 0 {
researchMap.KnowledgeItems = mergeStringLists(researchMap.KnowledgeItems, notes)
}
}
func similarAccountEntitiesFromWeb(in []libviral.SimilarAccount) []missionentity.SimilarAccount {
out := make([]missionentity.SimilarAccount, 0, len(in))
for _, item := range in {
out = append(out, missionentity.SimilarAccount{
Username: item.Username,
Reason: item.Reason,
Source: item.Source,
MatchedSource: item.MatchedSource,
Confidence: item.Confidence,
Status: missionentity.SimilarAccountStatusRecommended,
ProfileURL: item.ProfileURL,
})
}
return out
}
func collectMissionKnowledgeNotes(
ctx context.Context,
client websearch.Client,
member placement.MemberContext,
mission *missiondomain.MissionSummary,
researchMap *missionentity.ResearchMap,
) []string {
seed := strings.TrimSpace(mission.SeedQuery)
if seed == "" {
return nil
}
queries := []string{seed + " 重點 懶人包"}
if len(researchMap.Questions) > 0 {
if q := strings.TrimSpace(researchMap.Questions[0]); q != "" && q != seed {
queries = append(queries, q)
}
}
seen := map[string]struct{}{}
lines := []string{}
for _, query := range queries {
if len(lines) >= 5 {
break
}
res, err := client.Search(ctx, websearch.SearchOptions{
Query: query,
Limit: 4,
Mode: websearch.ModeKnowledgeExpand,
Country: member.BraveCountry,
SearchLang: member.BraveSearchLang,
UserLocation: member.ExaUserLocation,
})
if err != nil || res.Status != "success" {
continue
}
for _, item := range res.Results {
if len(lines) >= 5 {
break
}
title := strings.TrimSpace(item.Title)
snippet := strings.TrimSpace(item.Snippet)
if title == "" && snippet == "" {
continue
}
key := strings.ToLower(title + "|" + snippet)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
line := title
if snippet != "" {
if len([]rune(snippet)) > 120 {
snippet = string([]rune(snippet)[:120])
}
line += "" + snippet
}
lines = append(lines, line)
}
}
if len(lines) == 0 {
return nil
}
return lines
}
func baseKnowledgeItems(parsed libviral.MissionResearchMap) []string {
items := []string{}
for _, pillar := range parsed.Pillars {
if strings.TrimSpace(pillar) != "" {
items = append(items, "內容支柱:"+strings.TrimSpace(pillar))
}
}
for _, question := range parsed.Questions {
if strings.TrimSpace(question) != "" {
items = append(items, "受眾問題:"+strings.TrimSpace(question))
}
}
if strings.TrimSpace(parsed.BenchmarkNotes) != "" {
items = append(items, "研究筆記:"+strings.TrimSpace(parsed.BenchmarkNotes))
}
return mergeStringLists(nil, items)
}
func defaultSelectedKnowledgeItems(items []string) []string {
if len(items) == 0 {
return nil
}
if len(items) > 6 {
return append([]string(nil), items[:6]...)
}
return append([]string(nil), items...)
}
func mergeStringLists(base []string, extra []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(base)+len(extra))
for _, item := range append(append([]string{}, base...), extra...) {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
out = append(out, item)
}
return out
}
func ensureSeedQuerySelected(seed string, selected []string) []string {
seed = strings.TrimSpace(seed)
if seed == "" {
return selected
}
for _, item := range selected {
if strings.EqualFold(strings.TrimSpace(item), seed) {
return selected
}
}
out := append([]string{seed}, selected...)
if len(out) > libviral.MaxScanTags {
out = out[:libviral.MaxScanTags]
}
return out
}
2026-06-26 08:37:04 +00:00
func copyMissionIDFromPayload(payload map[string]any) string {
if id := stringField(payload, "copy_mission_id"); id != "" {
return id
}
return strings.TrimSpace(stringField(payload, "mission_id"))
}