thread-master/backend/internal/library/outreach/generate_draft.go

189 lines
6.1 KiB
Go
Raw Normal View History

2026-07-01 08:42:51 +00:00
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
2026-07-03 14:42:19 +00:00
}