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