391 lines
12 KiB
Go
391 lines
12 KiB
Go
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"))
|
||
}
|