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

391 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package job
import (
"context"
"fmt"
"strings"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/library/placement"
libviral "haixun-backend/internal/library/viral"
"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"
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
}
systemPrompt := libviral.BuildMissionResearchMapSystemPrompt()
userPrompt := 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...),
})
// ResearchGenerateRequest raises max_tokens so the full research map JSON is
// not truncated mid-object (truncation is the main cause of parse failures).
result, 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: userPrompt},
},
}))
if err != nil {
return err
}
parsed, err := libviral.ParseMissionResearchMapOutput(result.Text)
if err != nil {
// Model sometimes replies with reasoning/prose and no JSON object.
// Retry once, explicitly demanding a raw JSON object.
jsonOnlyResult, retryErr := 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: userPrompt},
{Role: "assistant", Content: result.Text},
{Role: "user", Content: libviral.MissionResearchMapJSONOnlyRetryPrompt()},
},
}))
if retryErr != nil {
return app.For(code.AI).SvcThirdParty("拷貝任務研究地圖 LLM 回傳無法解析:" + err.Error())
}
parsed, err = libviral.ParseMissionResearchMapOutput(jsonOnlyResult.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,
KnowledgeItems: baseKnowledgeItems(parsed),
BenchmarkNotes: parsed.BenchmarkNotes,
SimilarAccounts: similarAccountsFromSummary(mission.ResearchMap.SimilarAccounts),
}
researchMap.SuggestedTags = entityTags
updateProgress("補充相似帳號與延伸知識…", 70)
enrichMissionResearchMap(ctx, deps, tenantID, ownerUID, mission, &researchMap)
if len(researchMap.SelectedKnowledgeItems) == 0 {
researchMap.SelectedKnowledgeItems = defaultSelectedKnowledgeItems(researchMap.KnowledgeItems)
}
selected := libviral.PickDefaultSelectedTags(parsed.SuggestedTags)
selected = ensureSeedQuerySelected(mission.SeedQuery, selected)
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
}
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
}
func copyMissionIDFromPayload(payload map[string]any) string {
if id := stringField(payload, "copy_mission_id"); id != "" {
return id
}
return strings.TrimSpace(stringField(payload, "mission_id"))
}