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,
|
|||
|
|
}
|
|||
|
|
}
|