package personacopy import ( "context" "fmt" "strings" "haixun-backend/internal/library/copyvoice" libmatrix "haixun-backend/internal/library/matrix" "haixun-backend/internal/library/placement" "haixun-backend/internal/library/style8d" "haixun-backend/internal/library/websearch" domai "haixun-backend/internal/model/ai/domain/usecase" aiusecase "haixun-backend/internal/model/ai/usecase" contentopsdomain "haixun-backend/internal/model/content_ops/domain/usecase" copydraftentity "haixun-backend/internal/model/copy_draft/domain/entity" copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" personadomain "haixun-backend/internal/model/persona/domain/usecase" placementusecase "haixun-backend/internal/model/placement/usecase" threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" ) type TopicMatrixInput struct { TenantID string OwnerUID string PersonaID string ContentPlanID string Topic string Brief string UseWebSearch bool DraftCount int } type TopicMatrixDeps struct { Persona personadomain.UseCase CopyDraft copydraftdomain.UseCase ContentOps contentopsdomain.UseCase ThreadsAccount threadsaccountdomain.UseCase Placement placementusecase.UseCase AI domai.UseCase } type ProgressFn func(summary string, percentage int) func RunTopicMatrix(ctx context.Context, deps TopicMatrixDeps, in TopicMatrixInput, progress ProgressFn) ([]copydraftdomain.CopyDraftSummary, error) { if progress == nil { progress = func(string, int) {} } topic := strings.TrimSpace(in.Topic) if topic == "" { return nil, fmt.Errorf("topic is required") } count := in.DraftCount if count <= 0 { count = 3 } if count < 1 { count = 1 } if count > 5 { count = 5 } progress("讀取人設語氣…", 10) persona, err := deps.Persona.Get(ctx, in.TenantID, in.OwnerUID, in.PersonaID) if err != nil { return nil, err } credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, in.TenantID, in.OwnerUID) if err != nil { return nil, err } providerID, err := aiusecase.MapWorkerProvider(credential.Provider) if err != nil { return nil, err } researchNotes := "" if in.UseWebSearch { progress("搜尋網路參考資料…", 25) researchNotes = searchTopicNotes(ctx, deps, in.TenantID, in.OwnerUID, topic, in.Brief) } personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief) feedbackNotes := recentFeedbackNotes(ctx, deps, in.TenantID, in.OwnerUID, in.PersonaID) knowledgeNotes := recentKnowledgeNotes(ctx, deps, in.TenantID, in.OwnerUID, in.PersonaID, topic) formulaNotes := formulaPoolNotes(ctx, deps, in.TenantID, in.OwnerUID, in.PersonaID) progress(fmt.Sprintf("呼叫 AI 產生 %d 篇草稿(%s / %s)…", count, credential.Provider, credential.Model), 45) parsed, err := libmatrix.GenerateCopyOutput(ctx, deps.AI, domai.GenerateRequest{ Provider: providerID, Model: credential.Model, Credential: domai.Credential{APIKey: credential.APIKey}, System: TopicMatrixSystemPrompt(), Messages: []domai.Message{{Role: "user", Content: TopicMatrixUserPrompt(topic, in.Brief, personaBlock, researchNotes, knowledgeNotes, formulaNotes, feedbackNotes, count)}}, }, count) if err != nil { return nil, err } progress("寫入草稿…", 85) created := make([]copydraftdomain.CopyDraftSummary, 0, len(parsed.Rows)) for _, row := range parsed.Rows { saved, saveErr := deps.CopyDraft.Create(ctx, copydraftdomain.CreateRequest{ TenantID: in.TenantID, OwnerUID: in.OwnerUID, PersonaID: in.PersonaID, ContentPlanID: in.ContentPlanID, DraftType: copydraftentity.DraftTypeMatrix, SortOrder: row.SortOrder, Text: row.Text, TopicTag: row.SearchTag, Angle: row.Angle, Hook: row.Hook, Rationale: row.Rationale, ReferenceNotes: row.ReferenceNotes, Sources: row.SourcePermalinks, AiScore: row.AiScore, FormulaScore: row.FormulaScore, BrandFitScore: row.BrandFitScore, RiskScore: row.RiskScore, SimilarityScore: row.SimilarityScore, EngagementPotential: row.EngagementPotential, FreshnessScore: row.FreshnessScore, ReviewSuggestion: row.ReviewSuggestion, }) if saveErr != nil { return created, saveErr } created = append(created, *saved) } progress(fmt.Sprintf("已產生 %d 篇草稿", len(created)), 100) return created, nil } func recentFeedbackNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, ownerUID, personaID string) string { if deps.ContentOps == nil { return "" } items, err := deps.ContentOps.ListFeedbackEvents(ctx, tenantID, ownerUID, personaID, 8) if err != nil || len(items) == 0 { return "" } var b strings.Builder for _, item := range items { decision := strings.TrimSpace(item.Decision) note := strings.TrimSpace(item.Note) if decision == "" && note == "" { continue } b.WriteString("- ") if decision != "" { b.WriteString(decision) } if note != "" { b.WriteString(":") b.WriteString(note) } b.WriteString("\n") } return strings.TrimSpace(b.String()) } func recentKnowledgeNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, ownerUID, personaID, topic string) string { if deps.ContentOps == nil { return "" } items, err := deps.ContentOps.ListKnowledgeChunks(ctx, tenantID, ownerUID, personaID, 12) if err != nil { return "" } var b strings.Builder for _, item := range items { content := strings.TrimSpace(item.Content) if content == "" { continue } b.WriteString("- ") b.WriteString(shorten(content, 220)) if item.RiskLevel != "" { b.WriteString("(風險:") b.WriteString(item.RiskLevel) b.WriteString(")") } b.WriteString("\n") } return strings.TrimSpace(b.String()) } func formulaPoolNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, ownerUID, personaID string) string { if deps.ContentOps == nil { return "" } items, err := deps.ContentOps.ListFormulaPools(ctx, tenantID, ownerUID, personaID, 12) if err != nil { return "" } var b strings.Builder for _, item := range items { b.WriteString("- ") b.WriteString(item.Type) b.WriteString(" / ") b.WriteString(item.Name) if item.Pattern != "" { b.WriteString(":") b.WriteString(item.Pattern) } if len(item.Avoid) > 0 { b.WriteString(";避免:") b.WriteString(strings.Join(item.Avoid, "、")) } b.WriteString("\n") } return strings.TrimSpace(b.String()) } func shorten(raw string, max int) string { r := []rune(strings.TrimSpace(raw)) if len(r) <= max { return string(r) } return string(r[:max]) + "..." } func searchTopicNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, uid, topic, brief string) string { research, err := deps.Placement.ResearchSettings(ctx, tenantID, uid) if err != nil || !placement.WebSearchAvailable(research) { return "" } memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, uid, research) if err != nil { return "" } webClient := websearch.New(memberCtx.WebSearchConfig()) if !webClient.Enabled() { return "" } resp, err := webClient.Search(ctx, websearch.SearchOptions{ Query: strings.TrimSpace(topic + " " + brief), Limit: 5, Mode: websearch.ModeKnowledgeExpand, }) if err != nil || len(resp.Results) == 0 { return "" } var b strings.Builder for _, item := range resp.Results { if item.Title != "" { b.WriteString("- ") b.WriteString(item.Title) b.WriteString("\n") } if item.Snippet != "" { b.WriteString(item.Snippet) b.WriteString("\n") } if item.URL != "" { b.WriteString(item.URL) b.WriteString("\n") } } return strings.TrimSpace(b.String()) } func TopicMatrixSystemPrompt() string { return strings.TrimSpace(`你是 Threads / Instagram 貼文編輯。請依人設語言指紋產出自然、可直接發布的繁體中文貼文矩陣。 ` + copyvoice.SystemRules() + ` 規則: - 不要每篇都用同一套 hook / 三段式 / 結尾問句。 - 不要企業八股、不要像教科書、不要一直喊口號。 - 人設定位決定誰在說,語言指紋決定怎麼說;必須遵守段落節奏、標點換行、知識轉譯與 CTA 習慣。 - 不要把人設介紹裡的物件/標籤硬寫進正文情境。例:人設寫 Y2K、咖啡、穿搭,只代表語感與審美,不代表每篇都要出現老歌、咖啡、穿搭、MV。 - 正文內容必須由【話題】與【補充】決定;人設不得蓋過主題。 - 每篇要有不同角度;長度與格式依人設習慣和主題型態決定,不要強制短文。 - 每次至少產出 3 種版本思路:穩定帳號風格、更有共鳴、更容易互動;若 count 超過 3,再補更短或更反差版本。 - text 只能放貼文正文,不要放標題、分析、公式、模板或說明。 - text 排版:像真人手機發文;可使用自然標點、空行、列點與少量 emoji。不要完全無標點,也不要每句硬換行,除非人設樣本就是這樣。 - 只回傳 JSON 物件:{"rows":[...]}。 - 每筆 row 必須包含 sort_order, search_tag, angle, hook, text, reference_notes, source_permalinks, rationale, ai_score, formula_score, brand_fit_score, risk_score, similarity_score, engagement_potential, freshness_score, review_suggestion。 - 分數欄位都是 0-100 整數:ai_score / formula_score / risk_score / similarity_score 越低越好;brand_fit_score / engagement_potential / freshness_score 越高越好。 - review_suggestion 只能是「可通過」「需人工確認」「建議重寫」之一。 - rationale 必須用繁中短列點包含:版本定位、AI感分數(0-100,越高越AI)、公式感分數(0-100)、人設符合度(0-100)、風險分數(0-100)、互動潛力(0-100)、審核建議(可通過/需人工確認/建議重寫)與原因。`) } func TopicMatrixUserPrompt(topic, brief, personaBlock, researchNotes, knowledgeNotes, formulaNotes, feedbackNotes string, count int) string { var b strings.Builder b.WriteString("請產生 ") b.WriteString(fmt.Sprintf("%d", count)) b.WriteString(" 篇 Threads 草稿。\n\n【話題】\n") b.WriteString(strings.TrimSpace(topic)) if brief = strings.TrimSpace(brief); brief != "" { b.WriteString("\n\n【補充】\n") b.WriteString(brief) } if personaBlock = strings.TrimSpace(personaBlock); personaBlock != "" { b.WriteString("\n\n【人設定位、語言指紋與禁忌(最高優先)】\n") b.WriteString(personaBlock) b.WriteString("\n\n") b.WriteString(copyvoice.PersonaCalibrationNote()) } if researchNotes = strings.TrimSpace(researchNotes); researchNotes != "" { b.WriteString("\n\n【網路資料參考】\n") b.WriteString(researchNotes) } if knowledgeNotes = strings.TrimSpace(knowledgeNotes); knowledgeNotes != "" { b.WriteString("\n\n【Knowledge Memory:帳號知識來源,優先採用但不要硬塞】\n") b.WriteString(knowledgeNotes) } if formulaNotes = strings.TrimSpace(formulaNotes); formulaNotes != "" { b.WriteString("\n\n【Formula Pool:可選公式池,請依 Content Plan 選擇,不要每篇都套同一套】\n") b.WriteString(formulaNotes) } if feedbackNotes = strings.TrimSpace(feedbackNotes); feedbackNotes != "" { b.WriteString("\n\n【最近人工審核回饋(Feedback Memory,必須吸收)】\n") b.WriteString(feedbackNotes) } b.WriteString("\n\n【品質閘門】\n請先自我檢查 AI 感、公式感、事實風險、人設一致性、與過去常見句型重複。高風險內容不得建議直接通過。") b.WriteString("\n\n請讓每篇的 angle 明確不同,search_tag 是短主題標籤不含 #。source_permalinks 可放參考 URL,沒有就空陣列。") return b.String() }