189 lines
6.1 KiB
Go
189 lines
6.1 KiB
Go
|
|
package outreach
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"strings"
|
|||
|
|
|
|||
|
|
app "haixun-backend/internal/library/errors"
|
|||
|
|
"haixun-backend/internal/library/errors/code"
|
|||
|
|
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"
|
|||
|
|
kgdomain "haixun-backend/internal/model/knowledge_graph/domain/usecase"
|
|||
|
|
outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase"
|
|||
|
|
personadomain "haixun-backend/internal/model/persona/domain/usecase"
|
|||
|
|
scanpostentity "haixun-backend/internal/model/scan_post/domain/entity"
|
|||
|
|
scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase"
|
|||
|
|
threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
type GenerateDraftRequest struct {
|
|||
|
|
TenantID string
|
|||
|
|
OwnerUID string
|
|||
|
|
BrandID string
|
|||
|
|
TopicID string
|
|||
|
|
ScanPostID string
|
|||
|
|
Count int
|
|||
|
|
VoicePersonaID string
|
|||
|
|
ProductID string
|
|||
|
|
Regenerate bool
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type GenerateDraftDeps struct {
|
|||
|
|
Brand branddomain.UseCase
|
|||
|
|
Persona personadomain.UseCase
|
|||
|
|
ScanPost scanpostusecase.UseCase
|
|||
|
|
KnowledgeGraph kgdomain.UseCase
|
|||
|
|
ThreadsAccount threadsaccountdomain.UseCase
|
|||
|
|
AI aiusecase.UseCase
|
|||
|
|
OutreachDraft outreachusecase.UseCase
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func GenerateDraft(ctx context.Context, deps GenerateDraftDeps, req GenerateDraftRequest) (*outreachusecase.DraftSummary, error) {
|
|||
|
|
scanPostID := strings.TrimSpace(req.ScanPostID)
|
|||
|
|
count := req.Count
|
|||
|
|
if count <= 0 {
|
|||
|
|
count = 2
|
|||
|
|
}
|
|||
|
|
if count > 4 {
|
|||
|
|
count = 4
|
|||
|
|
}
|
|||
|
|
if scanPostID == "" {
|
|||
|
|
return nil, app.For(code.Brand).InputMissingRequired("scan_post_id is required")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
brand, err := deps.Brand.Get(ctx, req.TenantID, req.OwnerUID, req.BrandID)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
voiceID := strings.TrimSpace(req.VoicePersonaID)
|
|||
|
|
if voiceID == "" {
|
|||
|
|
return nil, app.For(code.Brand).InputMissingRequired("voice_persona_id is required")
|
|||
|
|
}
|
|||
|
|
vp, err := deps.Persona.Get(ctx, req.TenantID, req.OwnerUID, voiceID)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
voicePersona := FormatVoicePersonaBlock(vp)
|
|||
|
|
if voicePersona == "" {
|
|||
|
|
return nil, app.For(code.Persona).InputMissingRequired("請先在人設頁填寫人設描述或完成 8D 風格分析")
|
|||
|
|
}
|
|||
|
|
preferredProductID := strings.TrimSpace(req.ProductID)
|
|||
|
|
if preferredProductID == "" {
|
|||
|
|
preferredProductID = strings.TrimSpace(brand.ProductID)
|
|||
|
|
}
|
|||
|
|
post, err := deps.ScanPost.Get(ctx, req.TenantID, req.OwnerUID, req.BrandID, scanPostID)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
pickedProduct := PickProductForOutreach(brand, preferredProductID, post.SearchTag, post.Text)
|
|||
|
|
productID := preferredProductID
|
|||
|
|
if pickedProduct != nil {
|
|||
|
|
productID = pickedProduct.ID
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
topicLabel := strings.TrimSpace(post.SearchTag)
|
|||
|
|
placementReason := BuildPlacementReason(post)
|
|||
|
|
if graph, graphErr := deps.KnowledgeGraph.Get(ctx, req.TenantID, req.OwnerUID, req.BrandID); 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)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
regenerate := req.Regenerate
|
|||
|
|
previousHint := ""
|
|||
|
|
if latest, latestErr := deps.OutreachDraft.GetLatestByScanPost(
|
|||
|
|
ctx, req.TenantID, req.OwnerUID, req.BrandID, strings.TrimSpace(req.TopicID), scanPostID,
|
|||
|
|
); latestErr == nil && latest != nil && len(latest.Drafts) > 0 {
|
|||
|
|
regenerate = true
|
|||
|
|
previousHint = FormatPreviousDraftsHint(latest)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
userPrompt, err := BuildUserPrompt(GenerateInput{
|
|||
|
|
Persona: voicePersona,
|
|||
|
|
TopicLabel: topicLabel,
|
|||
|
|
AudienceBrief: brand.TargetAudience,
|
|||
|
|
ProductBrief: ProductBriefForBrand(brand, productID, post.SearchTag, post.Text),
|
|||
|
|
PlacementReason: placementReason,
|
|||
|
|
TargetText: post.Text,
|
|||
|
|
AuthorName: post.Author,
|
|||
|
|
Count: count,
|
|||
|
|
Regenerate: regenerate,
|
|||
|
|
PreviousDraftsHint: previousHint,
|
|||
|
|
})
|
|||
|
|
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")
|
|||
|
|
}
|
|||
|
|
systemPrompt = strings.TrimSpace(systemPrompt) + "\n\n【最高優先】以下人設與語氣是硬約束,每則留言都必須像這個角色親自回覆,不得變成通用網友口吻:\n" + strings.TrimSpace(voicePersona)
|
|||
|
|
|
|||
|
|
credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, req.TenantID, req.OwnerUID)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
temp := 0.88
|
|||
|
|
result, err := deps.AI.GenerateText(ctx, domai.GenerateRequest{
|
|||
|
|
Provider: providerID,
|
|||
|
|
Model: credential.Model,
|
|||
|
|
Credential: domai.Credential{
|
|||
|
|
APIKey: credential.APIKey,
|
|||
|
|
},
|
|||
|
|
System: systemPrompt,
|
|||
|
|
Messages: []domai.Message{
|
|||
|
|
{Role: "user", Content: userPrompt},
|
|||
|
|
},
|
|||
|
|
Temperature: &temp,
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
parsed, err := 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 := deps.OutreachDraft.Create(ctx, outreachusecase.CreateRequest{
|
|||
|
|
TenantID: req.TenantID,
|
|||
|
|
OwnerUID: req.OwnerUID,
|
|||
|
|
BrandID: req.BrandID,
|
|||
|
|
TopicID: strings.TrimSpace(req.TopicID),
|
|||
|
|
ScanPostID: scanPostID,
|
|||
|
|
Relevance: parsed.Relevance,
|
|||
|
|
Reason: parsed.Reason,
|
|||
|
|
Drafts: draftItems,
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
_, _ = deps.ScanPost.UpdateOutreach(ctx, scanpostusecase.UpdateOutreachRequest{
|
|||
|
|
TenantID: req.TenantID,
|
|||
|
|
OwnerUID: req.OwnerUID,
|
|||
|
|
BrandID: req.BrandID,
|
|||
|
|
PostID: scanPostID,
|
|||
|
|
Status: scanpostentity.OutreachStatusDrafted,
|
|||
|
|
})
|
|||
|
|
return saved, nil
|
|||
|
|
}
|