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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-28 12:25:39 +00:00
|
|
|
|
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{
|
2026-06-26 08:37:04 +00:00
|
|
|
|
Provider: providerID,
|
|
|
|
|
|
Model: credential.Model,
|
|
|
|
|
|
Credential: domai.Credential{
|
|
|
|
|
|
APIKey: credential.APIKey,
|
|
|
|
|
|
},
|
2026-06-28 12:25:39 +00:00
|
|
|
|
System: systemPrompt,
|
2026-06-26 08:37:04 +00:00
|
|
|
|
Messages: []domai.Message{
|
2026-06-28 12:25:39 +00:00
|
|
|
|
{Role: "user", Content: userPrompt},
|
2026-06-26 08:37:04 +00:00
|
|
|
|
},
|
2026-06-28 12:25:39 +00:00
|
|
|
|
}))
|
2026-06-26 08:37:04 +00:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
parsed, err := libviral.ParseMissionResearchMapOutput(result.Text)
|
|
|
|
|
|
if err != nil {
|
2026-06-28 12:25:39 +00:00
|
|
|
|
// 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())
|
|
|
|
|
|
}
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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"))
|
|
|
|
|
|
}
|