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

322 lines
12 KiB
Go
Raw Permalink Normal View History

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