thread-master/backend/internal/logic/copy_mission/inspire_copy_mission_logic.go

374 lines
12 KiB
Go

// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"strings"
"time"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/library/placement"
"haixun-backend/internal/library/style8d"
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"
contentops "haixun-backend/internal/model/content_ops/domain/usecase"
personadomain "haixun-backend/internal/model/persona/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type InspireCopyMissionLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewInspireCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *InspireCopyMissionLogic {
return &InspireCopyMissionLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspirationHandlerReq) (resp *types.CopyMissionInspirationData, err error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
personaID := strings.TrimSpace(req.PersonaID)
if personaID == "" {
return nil, app.For(code.Persona).InputMissingRequired("persona_id is required")
}
persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
if err != nil {
return nil, err
}
credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
if err != nil {
return nil, err
}
providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
if err != nil {
return nil, err
}
keyword := strings.TrimSpace(req.Keyword)
trends, sources, searchUsed := []libviral.MissionInspireTrendSnippet(nil), []types.CopyMissionInspirationSourceData(nil), false
if req.UseWebSearch {
trends, sources, searchUsed = l.collectTrendSignals(tenantID, uid, persona, keyword)
}
llmOnly := len(trends) == 0
if keyword != "" && llmOnly {
trends = append(trends, libviral.MissionInspireTrendSnippet{
Query: keyword,
Title: "使用者指定方向",
Snippet: keyword,
})
}
personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief)
temp := 0.72
result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{
Provider: providerID,
Model: credential.Model,
Credential: domai.Credential{APIKey: credential.APIKey},
System: libviral.BuildMissionInspireSystemPrompt(),
Temperature: &temp,
Messages: []domai.Message{{Role: "user", Content: libviral.BuildMissionInspireUserPrompt(libviral.MissionInspireInput{
PersonaDisplayName: persona.DisplayName,
PersonaBrief: persona.Brief,
PersonaBlock: personaBlock,
StyleBenchmark: persona.StyleBenchmark,
AvoidTopics: cleanAvoidTopics(req.AvoidTopics),
ContentDirection: req.ContentDirection,
UserKeyword: keyword,
TrendSnippets: trends,
WebSearchProvider: sourceLabel(searchUsed, sources),
LLMOnly: llmOnly,
})}},
})
if err != nil {
return nil, app.For(code.AI).SvcThirdParty("起號靈感生成失敗:" + err.Error())
}
parsed, err := libviral.ParseMissionInspireOutput(result.Text)
if err != nil {
return nil, app.For(code.AI).SvcThirdParty("起號靈感回傳無法解析:" + err.Error())
}
topicCandidate, plan := l.persistInspiration(tenantID, uid, personaID, parsed, searchUsed)
return &types.CopyMissionInspirationData{
TopicCandidateID: topicCandidate,
ContentPlanID: plan,
Label: parsed.Label,
SeedQuery: parsed.SeedQuery,
Brief: parsed.Brief,
TrendReason: parsed.TrendReason,
TrendKeywords: parsed.TrendKeywords,
Angles: parsed.Angles,
Mission: parsed.Mission,
TargetAudience: parsed.TargetAudience,
OpeningType: parsed.OpeningType,
BodyType: parsed.BodyType,
Emotion: parsed.Emotion,
CtaType: parsed.CtaType,
RiskLevel: parsed.RiskLevel,
Avoid: parsed.Avoid,
Sources: sources,
WebSearchUsed: searchUsed,
Message: "已找到一個可延伸的話題與內容計畫",
}, nil
}
func (l *InspireCopyMissionLogic) persistInspiration(tenantID, uid, personaID string, parsed libviral.MissionInspireOutput, searchUsed bool) (string, string) {
if l.svcCtx.ContentOps == nil {
return "", ""
}
riskScore := riskScoreFromLevel(parsed.RiskLevel)
category := parsed.Mission
if category == "" {
category = "候選話題"
}
source := "llm"
if searchUsed {
source = "topic_radar"
}
topic, err := l.svcCtx.ContentOps.CreateTopicCandidate(l.ctx, contentops.TopicCandidateInput{
TenantID: tenantID,
OwnerUID: uid,
PersonaID: personaID,
Name: parsed.Label,
Source: source,
Category: category,
SeedQuery: parsed.SeedQuery,
TrendReason: parsed.TrendReason,
TrendKeywords: parsed.TrendKeywords,
HeatScore: scoreBySearch(searchUsed),
FitScore: 82,
InteractionScore: 70,
ExtendScore: 78,
RiskScore: riskScore,
RecommendedMissions: []string{parsed.Mission},
})
if err != nil {
l.Errorf("persist topic candidate: %v", err)
return "", ""
}
angle := ""
if len(parsed.Angles) > 0 {
angle = parsed.Angles[0]
}
plan, err := l.svcCtx.ContentOps.CreateContentPlan(l.ctx, contentops.ContentPlanInput{
TenantID: tenantID,
OwnerUID: uid,
PersonaID: personaID,
TopicCandidateID: topic.ID,
Topic: parsed.Label,
Mission: fallbackString(parsed.Mission, "互動文"),
TargetAudience: parsed.TargetAudience,
Angle: angle,
OpeningType: parsed.OpeningType,
BodyType: parsed.BodyType,
Emotion: parsed.Emotion,
CtaType: parsed.CtaType,
RiskLevel: parsed.RiskLevel,
RequiresHumanReview: parsed.RiskLevel != "low",
Avoid: parsed.Avoid,
})
if err != nil {
l.Errorf("persist content plan: %v", err)
return topic.ID, ""
}
return topic.ID, plan.ID
}
func scoreBySearch(searchUsed bool) int {
if searchUsed {
return 76
}
return 55
}
func riskScoreFromLevel(level string) int {
switch strings.ToLower(strings.TrimSpace(level)) {
case "high":
return 75
case "low":
return 20
default:
return 45
}
}
func fallbackString(value, fallback string) string {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
return fallback
}
func (l *InspireCopyMissionLogic) collectTrendSignals(tenantID, uid string, persona *personadomain.PersonaSummary, keyword string) ([]libviral.MissionInspireTrendSnippet, []types.CopyMissionInspirationSourceData, bool) {
if persona == nil {
return nil, nil, false
}
trends := make([]libviral.MissionInspireTrendSnippet, 0, 12)
seen := map[string]struct{}{}
addTrend := func(item libviral.MissionInspireTrendSnippet) {
item.Query = strings.TrimSpace(item.Query)
item.Title = strings.TrimSpace(item.Title)
item.Snippet = strings.TrimSpace(item.Snippet)
item.URL = strings.TrimSpace(item.URL)
if item.Title == "" && item.Snippet == "" {
return
}
if isNewsLikeInspiration(item.Title, item.Snippet, item.URL) {
return
}
key := strings.ToLower(item.Query + "|" + item.Title + "|" + item.Snippet)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
trends = append(trends, item)
}
for _, item := range libviral.CollectFreeTopicTrends(l.ctx, keyword, persona.Brief) {
addTrend(item)
}
research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
if err != nil {
sources := inspirationSourcesFromTrends(trends)
return trends, sources, len(sources) > 0
}
memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
if err != nil {
sources := inspirationSourcesFromTrends(trends)
return trends, sources, len(sources) > 0
}
if placement.WebSearchAvailable(research) {
client := websearch.New(memberCtx.WebSearchConfig())
for _, item := range libviral.CollectMissionInspireTrends(l.ctx, client, memberCtx, strings.TrimSpace(persona.Brief+" "+keyword), persona.StyleBenchmark) {
addTrend(item)
}
if keyword != "" && client.Enabled() {
for _, query := range []string{
"Threads 台灣 " + keyword + " 熱門 關鍵字 討論",
keyword + " 請問 心得 推薦 Threads",
keyword + " 痛點 經驗 怎麼辦 社群",
keyword + " Dcard PTT Threads 熱門 討論",
} {
res, searchErr := client.Search(l.ctx, websearch.SearchOptions{
Query: query,
Limit: 4,
Mode: websearch.ModeKnowledgeExpand,
Country: memberCtx.BraveCountry,
SearchLang: memberCtx.BraveSearchLang,
UserLocation: memberCtx.ExaUserLocation,
StartPublishedDate: placement.FormatPublishedAfterISO(14, time.Now().UTC()),
})
if searchErr != nil || res.Status != "success" {
continue
}
for _, result := range res.Results {
addTrend(libviral.MissionInspireTrendSnippet{
Query: query,
Title: result.Title,
Snippet: result.Snippet,
URL: result.URL,
})
}
}
}
}
if keyword != "" && len(trends) < 8 {
for _, item := range libviral.CollectMissionInspireThreadsTrends(l.ctx, libviral.MissionInspireThreadsInput{
Keyword: keyword,
PersonaBrief: persona.Brief,
StyleBenchmark: persona.StyleBenchmark,
Pillars: persona.CopyResearchMap.Pillars,
Questions: persona.CopyResearchMap.Questions,
Member: memberCtx,
}) {
addTrend(item)
}
}
if len(trends) > 10 {
trends = trends[:10]
}
sources := inspirationSourcesFromTrends(trends)
return trends, sources, len(sources) > 0
}
func isNewsLikeInspiration(title, snippet, url string) bool {
text := strings.ToLower(title + " " + snippet + " " + url)
newsSignals := []string{"新聞", "快訊", "中央社", "聯合新聞", "ettoday", "tvbs", "setn", "ctwant", "chinatimes", "ltn.com", "news"}
socialSignals := []string{"threads", "dcard", "ptt", "請問", "心得", "推薦", "有人", "怎麼辦", "討論", "留言"}
newsScore := 0
for _, signal := range newsSignals {
if strings.Contains(text, strings.ToLower(signal)) {
newsScore++
}
}
if newsScore == 0 {
return false
}
for _, signal := range socialSignals {
if strings.Contains(text, strings.ToLower(signal)) {
return false
}
}
return true
}
func inspirationSourcesFromTrends(trends []libviral.MissionInspireTrendSnippet) []types.CopyMissionInspirationSourceData {
sources := make([]types.CopyMissionInspirationSourceData, 0, len(trends))
for _, item := range trends {
sources = append(sources, types.CopyMissionInspirationSourceData{
Query: item.Query,
Title: item.Title,
Snippet: item.Snippet,
URL: item.URL,
})
}
return sources
}
func sourceLabel(used bool, sources []types.CopyMissionInspirationSourceData) string {
if !used || len(sources) == 0 {
return ""
}
return "Free Trend / Web / Threads Search"
}
func cleanAvoidTopics(items []string) []string {
out := make([]string, 0, len(items))
seen := map[string]struct{}{}
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
key := strings.ToLower(item)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, item)
if len(out) >= 8 {
break
}
}
return out
}