291 lines
8.3 KiB
Go
291 lines
8.3 KiB
Go
// 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,
|
||
}
|
||
}
|