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