thread-master/backend/internal/library/personacopy/topic_matrix.go

322 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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