thread-master/internal/logic/brand/generate_outreach_drafts_lo...

291 lines
8.3 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.

// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package brand
import (
"context"
"fmt"
"strings"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
libkg "haixun-backend/internal/library/knowledge"
liboutreach "haixun-backend/internal/library/outreach"
libplacement "haixun-backend/internal/library/placement"
libprompt "haixun-backend/internal/library/prompt"
domai "haixun-backend/internal/model/ai/domain/usecase"
aiusecase "haixun-backend/internal/model/ai/usecase"
branddomain "haixun-backend/internal/model/brand/domain/usecase"
outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase"
scanpostentity "haixun-backend/internal/model/scan_post/domain/entity"
scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type GenerateOutreachDraftsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGenerateOutreachDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateOutreachDraftsLogic {
return &GenerateOutreachDraftsLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GenerateOutreachDraftsLogic) GenerateOutreachDrafts(req *types.GenerateOutreachDraftsHandlerReq) (resp *types.GenerateOutreachDraftsData, err error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
scanPostID := strings.TrimSpace(req.ScanPostID)
count := 2
if req.Count > 0 {
count = req.Count
}
if scanPostID == "" {
return nil, app.For(code.Brand).InputMissingRequired("scan_post_id is required")
}
if count > 4 {
count = 4
}
brand, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID)
if err != nil {
return nil, err
}
voicePersona := ""
voiceID := strings.TrimSpace(req.VoicePersonaID)
if voiceID != "" {
vp, vpErr := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, voiceID)
if vpErr != nil {
return nil, vpErr
}
voicePersona = vp.Persona
}
productID := strings.TrimSpace(req.ProductID)
if productID == "" {
productID = strings.TrimSpace(brand.ProductID)
}
post, err := l.svcCtx.ScanPost.Get(l.ctx, tenantID, uid, req.ID, scanPostID)
if err != nil {
return nil, err
}
topicLabel := strings.TrimSpace(post.SearchTag)
placementReason := buildPlacementReason(post)
if graph, graphErr := l.svcCtx.KnowledgeGraph.Get(l.ctx, tenantID, uid, req.ID); graphErr == nil && graph != nil {
if node := findGraphNode(graph.Nodes, post.GraphNodeID); node != nil {
if strings.TrimSpace(node.Label) != "" {
topicLabel = strings.TrimSpace(node.Label)
}
placementReason = mergePlacementReason(placementReason, node, post)
}
}
userPrompt, err := liboutreach.BuildUserPrompt(liboutreach.GenerateInput{
Persona: voicePersona,
TopicLabel: topicLabel,
AudienceBrief: brand.TargetAudience,
ProductBrief: productBriefForBrand(brand, productID, post.SearchTag),
PlacementReason: placementReason,
TargetText: post.Text,
AuthorName: post.Author,
Count: count,
})
if err != nil {
return nil, app.For(code.AI).SysInternal("outreach user prompt load failed")
}
systemPrompt, err := libprompt.OutreachPlacementSystem()
if err != nil {
return nil, app.For(code.AI).SysInternal("outreach system prompt load failed")
}
if strings.TrimSpace(voicePersona) != "" {
systemPrompt = strings.TrimSpace(systemPrompt) + "\n\n人設與語氣\n" + strings.TrimSpace(voicePersona)
}
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
}
result, err := l.svcCtx.AI.GenerateText(l.ctx, 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 nil, err
}
parsed, err := liboutreach.ParseGenerateOutput(result.Text)
if err != nil {
return nil, app.For(code.AI).SvcThirdParty("獲客留言 LLM 回傳無法解析:" + err.Error())
}
draftItems := make([]outreachusecase.DraftItem, 0, len(parsed.Drafts))
for _, item := range parsed.Drafts {
draftItems = append(draftItems, outreachusecase.DraftItem{
Text: item.Text,
Angle: item.Angle,
Rationale: item.Rationale,
})
}
saved, err := l.svcCtx.OutreachDraft.Create(l.ctx, outreachusecase.CreateRequest{
TenantID: tenantID,
OwnerUID: uid,
BrandID: req.ID,
TopicID: strings.TrimSpace(req.TopicID),
ScanPostID: scanPostID,
Relevance: parsed.Relevance,
Reason: parsed.Reason,
Drafts: draftItems,
})
if err != nil {
return nil, err
}
_, _ = l.svcCtx.ScanPost.UpdateOutreach(l.ctx, scanpostusecase.UpdateOutreachRequest{
TenantID: tenantID,
OwnerUID: uid,
BrandID: req.ID,
PostID: scanPostID,
Status: scanpostentity.OutreachStatusDrafted,
})
return toOutreachDraftData(saved), nil
}
func findGraphNode(nodes []libkg.Node, nodeID string) *libkg.Node {
nodeID = strings.TrimSpace(nodeID)
if nodeID == "" {
return nil
}
for i := range nodes {
if nodes[i].ID == nodeID {
return &nodes[i]
}
}
return nil
}
func buildPlacementReason(post *scanpostusecase.ScanPostSummary) string {
if post == nil {
return ""
}
parts := []string{}
if post.Priority == "gold" {
parts = append(parts, "雙軌命中(相關+近期)")
} else if post.Priority == "recent" {
parts = append(parts, "近期軌命中")
}
if post.SolvedByProduct {
parts = append(parts, "產品可解此需求")
}
if post.ProductFitScore > 0 {
parts = append(parts, fmt.Sprintf("產品匹配 %d", post.ProductFitScore))
}
return strings.Join(parts, "")
}
func mergePlacementReason(base string, node *libkg.Node, post *scanpostusecase.ScanPostSummary) string {
parts := []string{}
if strings.TrimSpace(base) != "" {
parts = append(parts, strings.TrimSpace(base))
}
if node != nil {
if strings.TrimSpace(node.PlacementValue) != "" {
parts = append(parts, "置入價值 "+strings.TrimSpace(node.PlacementValue))
}
if node.ProductFitScore > 0 && (post == nil || post.ProductFitScore == 0) {
parts = append(parts, fmt.Sprintf("節點產品匹配 %d", node.ProductFitScore))
}
}
return strings.Join(parts, "")
}
func productBriefForBrand(brand *branddomain.BrandSummary, productID, searchTag string) string {
if brand == nil {
return ""
}
if product := pickProductForOutreach(brand, productID, searchTag); product != nil {
merged := libplacement.BuildMergedProductContext(brand.DisplayName, product.ProductContext, product.Label)
if pb := libplacement.ProductBriefFromContext(merged); pb != "" {
return pb
}
}
if pb := libplacement.ProductBriefFromContext(brand.ProductContext); pb != "" {
return pb
}
return strings.TrimSpace(brand.ProductBrief)
}
func pickProductForOutreach(brand *branddomain.BrandSummary, productID, searchTag string) *branddomain.ProductSummary {
if brand == nil || len(brand.Products) == 0 {
return nil
}
if id := strings.TrimSpace(productID); id != "" {
for i := range brand.Products {
if brand.Products[i].ID == id {
return &brand.Products[i]
}
}
}
tag := strings.TrimSpace(searchTag)
if tag == "" {
return &brand.Products[0]
}
bestScore := -1
var best *branddomain.ProductSummary
for i := range brand.Products {
score := libplacement.ScoreProductForTag(tag, brand.Products[i].MatchTags)
if score > bestScore {
bestScore = score
best = &brand.Products[i]
}
}
if best != nil && bestScore > 0 {
return best
}
return &brand.Products[0]
}
func toOutreachDraftData(saved *outreachusecase.DraftSummary) *types.GenerateOutreachDraftsData {
if saved == nil {
return nil
}
drafts := make([]types.OutreachDraftItemData, 0, len(saved.Drafts))
for _, item := range saved.Drafts {
drafts = append(drafts, types.OutreachDraftItemData{
Text: item.Text,
Angle: item.Angle,
Rationale: item.Rationale,
})
}
return &types.GenerateOutreachDraftsData{
ID: saved.ID,
ScanPostID: saved.ScanPostID,
Relevance: saved.Relevance,
Reason: saved.Reason,
Drafts: drafts,
CreateAt: saved.CreateAt,
}
}