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

291 lines
8.3 KiB
Go
Raw Normal View History

2026-06-26 08:37:04 +00:00
// 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,
}
}