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 }