322 lines
12 KiB
Go
322 lines
12 KiB
Go
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()
|
||
}
|