thread-master/apps/backend/internal/module/studio/usecase/style_analyze.go

536 lines
16 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 usecase
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"unicode/utf8"
"apps/backend/internal/module/studio/domain"
)
// 8D keys — 與前端 DIM_ORDER 對齊
var dimKeys = []string{
"d1Tone", "d2Structure", "d3Interaction", "d4Topics",
"d5Rhythm", "d6Visual", "d7Conversion", "d8Risk",
}
func splitSamples(raw string, max int) []string {
parts := regexp.MustCompile(`\n\s*---\s*\n|\n{3,}`).Split(raw, -1)
out := make([]string, 0, max)
for _, p := range parts {
p = strings.TrimSpace(p)
if utf8.RuneCountInString(p) < 8 {
continue
}
out = append(out, p)
if max > 0 && len(out) >= max {
break
}
}
if len(out) == 0 && strings.TrimSpace(raw) != "" {
out = append(out, strings.TrimSpace(raw))
}
return out
}
func pickTone(blob string) string {
switch {
case regexp.MustCompile(`哈哈|笑死|哭|靠|真的假的`).MatchString(blob):
return "輕鬆吐槽、情緒外露"
case regexp.MustCompile(`建議|成分|數據|對比|規格`).MatchString(blob):
return "務實、有依據、少廢話"
case regexp.MustCompile(`懂|抱抱|辛苦|没关系|沒關係|一起`).MatchString(blob):
return "共感、溫柔、像朋友"
default:
return "口語親近、不端著"
}
}
func pickHooks(blob string) string {
switch {
case strings.ContainsAny(blob, "?"):
return "用真心疑問開場,邀請別人補經驗"
case regexp.MustCompile(`有人|大家|求`).MatchString(blob):
return "先丟痛點再求推坑/經驗"
default:
return "先講自己卡關的情境,再拋問題"
}
}
func pickEvidence(samples []string, max int) []string {
out := make([]string, 0, max)
for _, s := range samples {
r := []rune(s)
if len(r) > 36 {
s = string(r[:36]) + "…"
}
out = append(out, s)
if len(out) >= max {
break
}
}
return out
}
// serializeDraftText — 與前端 fingerprint 格式一致(【標籤】)
func serializeDraftText(d domain.PersonaDraft) string {
var lines []string
push := func(label, value string) {
v := strings.TrimSpace(value)
if v == "" {
return
}
lines = append(lines, "【"+label+"】\n"+v)
}
push("我是誰", d.Identity)
push("語氣", d.Tone)
push("對誰說", d.Audience)
push("開場鉤子", d.Hooks)
push("語言指紋", d.LanguageFingerprint)
push("節奏", d.Rhythm)
push("標點", d.Punctuation)
push("內容套路", d.ContentPatterns)
push("知識轉譯", d.KnowledgeTranslation)
push("CTA", d.CtaStyle)
push("像他會說的話", d.Examples)
push("絕不怎麼說", d.Avoid)
return strings.Join(lines, "\n\n")
}
func buildDimensions(draft domain.PersonaDraft, samples []string, dims map[string]domain.StyleDimension) map[string]domain.StyleDimension {
ev := pickEvidence(samples, 2)
out := make(map[string]domain.StyleDimension, 8)
// 規則底稿
defaults := map[string]string{
"d1Tone": draft.Tone,
"d2Structure": draft.ContentPatterns,
"d3Interaction": joinNonEmpty("", draft.Hooks, draft.CtaStyle),
"d4Topics": joinNonEmpty("", draft.Audience, "常談生活/實用經驗"),
"d5Rhythm": draft.Rhythm,
"d6Visual": draft.Punctuation,
"d7Conversion": draft.CtaStyle,
"d8Risk": draft.Avoid,
}
for _, k := range dimKeys {
sum := ""
var evidence []string
if dims != nil {
if d, ok := dims[k]; ok {
sum = strings.TrimSpace(d.Summary)
evidence = d.Evidence
}
}
if sum == "" {
sum = strings.TrimSpace(defaults[k])
}
if sum == "" {
sum = "(待補)"
}
if len(evidence) == 0 {
evidence = append([]string(nil), ev...)
}
out[k] = domain.StyleDimension{Summary: sum, Evidence: evidence}
}
return out
}
func joinNonEmpty(sep string, parts ...string) string {
var ok []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
ok = append(ok, p)
}
}
return strings.Join(ok, sep)
}
func splitAvoidList(avoid string) []string {
var out []string
for _, a := range regexp.MustCompile(`[、,;/|]`).Split(avoid, -1) {
a = strings.TrimSpace(a)
if a != "" {
out = append(out, a)
}
}
if len(out) == 0 {
return []string{"硬銷", "客服腔", "條列說教", "AI 腔"}
}
return out
}
func isPlaceholderName(name string) bool {
n := strings.TrimSpace(name)
if n == "" {
return true
}
// 前端新建預設/測試名
switch n {
case "新人設", "新身份", "未命名人設", "未命名", "分析", "對標", "新", "New persona", "Untitled":
return true
}
return strings.HasPrefix(n, "人設 ") || strings.HasPrefix(n, "persona")
}
func shortNameFromIdentity(identity, fallback string) string {
id := strings.TrimSpace(identity)
if id == "" {
return fallback
}
// 取第一句或前 16 字
if i := strings.IndexAny(id, ",。;;"); i > 0 && i <= 24 {
id = id[:i]
}
r := []rune(id)
if len(r) > 16 {
id = string(r[:16])
}
id = strings.TrimSpace(id)
if id == "" {
return fallback
}
return id
}
// finalizePersonaAnalysis 寫入概要 + 指紋 + 8D分析完成後一律覆寫可分析欄位
func finalizePersonaAnalysis(
p *domain.Persona,
draft domain.PersonaDraft,
samples []string,
source, username, sourceLabel, brief, nameHint string,
dims map[string]domain.StyleDimension,
) {
now := domain.NowNano()
p.Status = domain.PersonaReady
p.Style.Source = source
p.Style.BenchmarkUsername = username
p.Style.SourceLabel = sourceLabel
p.Style.SampleCount = len(samples)
p.Style.AnalyzedAt = now
p.Style.SamplePreviews = make([]string, 0, min(5, len(samples)))
for i, s := range samples {
if i >= 5 {
break
}
r := []rune(s)
if len(r) > 100 {
s = string(r[:100]) + "…"
}
p.Style.SamplePreviews = append(p.Style.SamplePreviews, s)
}
p.Style.Draft = draft
p.Style.DraftText = serializeDraftText(draft)
p.Style.Dimensions = buildDimensions(draft, samples, dims)
p.Voice = draft.Tone
// 概要 brief分析後一律填LLM brief 優先)
brief = strings.TrimSpace(brief)
if brief == "" {
brief = joinNonEmpty("。", draft.Identity, draft.Tone+";對「"+draft.Audience+"」說話")
}
p.Brief = brief
// 名稱:仍是佔位名時用 identity / hint 補上
if isPlaceholderName(p.Name) {
hint := strings.TrimSpace(nameHint)
if hint == "" {
hint = shortNameFromIdentity(draft.Identity, p.Name)
}
if hint != "" && !isPlaceholderName(hint) {
p.Name = hint
} else if draft.Identity != "" {
p.Name = shortNameFromIdentity(draft.Identity, "寫作人設")
}
}
// 護欄:分析後覆寫 avoid / 預設字數 / 禁 AI 腔
p.Guard.BanAiTone = true
if p.Guard.MaxChars == 0 {
p.Guard.MaxChars = 280
}
if draft.Avoid != "" {
p.Guard.Avoid = splitAvoidList(draft.Avoid)
} else if len(p.Guard.Avoid) == 0 {
p.Guard.Avoid = []string{"硬銷", "客服腔", "條列說教", "AI 腔"}
}
// notes 放分析來源摘要(不覆寫使用者手動長註?分析後可寫來源)
if source == "benchmark" && username != "" {
p.Notes = fmt.Sprintf("對標 @%s · 樣本 %d 則 · 已寫入概要8D指紋", username, len(samples))
} else if sourceLabel != "" {
p.Notes = fmt.Sprintf("來源:%s · 樣本 %d 則 · 已寫入概要8D指紋", sourceLabel, len(samples))
}
p.UpdatedAt = now
}
// applyStyleFromSamples fills persona style from real post samples (中文啟發式,可接 AI 再精修).
func applyStyleFromSamples(p *domain.Persona, samples []string, source string, username, sourceLabel string) {
blob := strings.Join(samples, "\n")
tone := pickTone(blob)
hooks := pickHooks(blob)
identity := p.Style.Draft.Identity
if identity == "" {
identity = p.Name
}
if isPlaceholderName(identity) {
identity = "生活觀察者"
}
if source == "benchmark" && username != "" {
identity = fmt.Sprintf("像 @%s 的發文者:%s", username, tone)
}
audience := p.Style.Draft.Audience
if audience == "" {
audience = "同溫層、會在 Threads 求經驗的人"
}
langFP := "短句、口語、少成語"
if regexp.MustCompile(`有時候|後來|其實|講真的`).MatchString(blob) {
langFP = "常用「有時候/後來/其實」當轉折"
}
rhythm := "一段講完再補一句問句"
if strings.Contains(blob, "\n") {
rhythm = "24 個短段落,段間空行"
}
punct := "逗號與問號為主,少驚嘆"
if strings.Contains(blob, "…") || strings.Contains(blob, "...") {
punct = "愛用省略號與問號"
}
avoid := "硬銷、條列教學、客服腔、假裝中立實則業配、AI 腔"
ex := ""
if len(samples) > 0 {
r := []rune(samples[0])
if len(r) > 80 {
ex = string(r[:80])
} else {
ex = samples[0]
}
}
draft := domain.PersonaDraft{
Identity: identity,
Tone: tone,
Audience: audience,
Hooks: hooks,
LanguageFingerprint: langFP,
Rhythm: rhythm,
Punctuation: punct,
ContentPatterns: "情境 → 感受或觀察 → 輕問一句",
KnowledgeTranslation: "先講使用情境,再講重點,不丟術語牆",
CtaStyle: "自然邀留言(「你們呢」「有人也…嗎」),不命令",
Examples: ex,
Avoid: avoid,
}
brief := fmt.Sprintf("%s。語氣%s主要對「%s」說話。", identity, tone, audience)
finalizePersonaAnalysis(p, draft, samples, source, username, sourceLabel, brief, "", nil)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
type aiDimJSON struct {
Summary string `json:"summary"`
Evidence []string `json:"evidence"`
}
type aiStyleJSON struct {
Identity string `json:"identity"`
Tone string `json:"tone"`
Audience string `json:"audience"`
Hooks string `json:"hooks"`
LanguageFingerprint string `json:"language_fingerprint"`
Rhythm string `json:"rhythm"`
Punctuation string `json:"punctuation"`
ContentPatterns string `json:"content_patterns"`
KnowledgeTranslation string `json:"knowledge_translation"`
CtaStyle string `json:"cta_style"`
Examples string `json:"examples"`
Avoid string `json:"avoid"`
Brief string `json:"brief"`
Name string `json:"name"`
Dimensions map[string]aiDimJSON `json:"dimensions"`
// 扁平 8D 後備
D1Tone aiDimJSON `json:"d1Tone"`
D2Structure aiDimJSON `json:"d2Structure"`
D3Interaction aiDimJSON `json:"d3Interaction"`
D4Topics aiDimJSON `json:"d4Topics"`
D5Rhythm aiDimJSON `json:"d5Rhythm"`
D6Visual aiDimJSON `json:"d6Visual"`
D7Conversion aiDimJSON `json:"d7Conversion"`
D8Risk aiDimJSON `json:"d8Risk"`
}
func buildStyleAnalysisPrompt(samples []string, username, sourceLabel string) string {
var b strings.Builder
b.WriteString("你是 Threads 寫作風格分析師。根據下列貼文樣本,用繁體中文(台灣用語)完整分析作者的寫作人設。\n")
if username != "" {
b.WriteString("對標帳號:@")
b.WriteString(username)
b.WriteString("\n")
}
if sourceLabel != "" {
b.WriteString("來源標籤:")
b.WriteString(sourceLabel)
b.WriteString("\n")
}
b.WriteString("\n【貼文樣本】\n")
for i, s := range samples {
b.WriteString(fmt.Sprintf("--- 樣本 %d ---\n%s\n", i+1, s))
}
b.WriteString(`
【輸出要求】
只輸出一個 JSON 物件(不要 markdown 代碼塊、不要前言)。必須填滿所有欄位,不可留空字串。
{
"name": "簡短人設名稱412 字,不要帳號)",
"brief": "兩到三句定位:是誰、對誰說、核心訊息",
"identity": "發文身份/角色(一句完整描述,不要只寫帳號名)",
"tone": "語氣(具體、可模仿)",
"audience": "主要對誰說話",
"hooks": "常見開場鉤子手法",
"language_fingerprint": "用詞/口頭禪/轉折特徵",
"rhythm": "段落節奏、長短句",
"punctuation": "標點與 emoji 習慣",
"content_patterns": "內容結構模式",
"knowledge_translation": "如何把知識/經驗講成人話",
"cta_style": "如何邀互動/收尾",
"examples": "摘一句最像他的原句或改寫示範",
"avoid": "模仿時要避開的寫法(可用頓號分隔多項)",
"dimensions": {
"d1Tone": { "summary": "D1 語氣人格一句話", "evidence": ["樣本短摘"] },
"d2Structure": { "summary": "D2 結構模板", "evidence": [] },
"d3Interaction": { "summary": "D3 互動方式", "evidence": [] },
"d4Topics": { "summary": "D4 主題分布", "evidence": [] },
"d5Rhythm": { "summary": "D5 發文節奏", "evidence": [] },
"d6Visual": { "summary": "D6 視覺語法換行emoji", "evidence": [] },
"d7Conversion": { "summary": "D7 轉換/邀約方式", "evidence": [] },
"d8Risk": { "summary": "D8 風險紅線", "evidence": [] }
}
}
`)
return b.String()
}
func collectDimsFromAI(parsed aiStyleJSON) map[string]domain.StyleDimension {
out := make(map[string]domain.StyleDimension)
put := func(k string, d aiDimJSON) {
sum := strings.TrimSpace(d.Summary)
if sum == "" {
return
}
out[k] = domain.StyleDimension{Summary: sum, Evidence: d.Evidence}
}
if parsed.Dimensions != nil {
for _, k := range dimKeys {
if d, ok := parsed.Dimensions[k]; ok {
put(k, d)
}
}
}
// 扁平後備
put("d1Tone", parsed.D1Tone)
put("d2Structure", parsed.D2Structure)
put("d3Interaction", parsed.D3Interaction)
put("d4Topics", parsed.D4Topics)
put("d5Rhythm", parsed.D5Rhythm)
put("d6Visual", parsed.D6Visual)
put("d7Conversion", parsed.D7Conversion)
put("d8Risk", parsed.D8Risk)
return out
}
// applyAIStyleJSON parses LLM JSON into persona style. Returns false if unusable.
func applyAIStyleJSON(p *domain.Persona, raw string, samples []string, source, username, sourceLabel string) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return false
}
// strip ```json fences if any
if i := strings.Index(raw, "{"); i >= 0 {
if j := strings.LastIndex(raw, "}"); j > i {
raw = raw[i : j+1]
}
}
var parsed aiStyleJSON
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return false
}
// need at least tone or identity
if strings.TrimSpace(parsed.Tone) == "" && strings.TrimSpace(parsed.Identity) == "" {
return false
}
coalesce := func(v, fallback string) string {
v = strings.TrimSpace(v)
if v == "" {
return fallback
}
return v
}
base := p.Style.Draft
draft := domain.PersonaDraft{
Identity: coalesce(parsed.Identity, base.Identity),
Tone: coalesce(parsed.Tone, base.Tone),
Audience: coalesce(parsed.Audience, base.Audience),
Hooks: coalesce(parsed.Hooks, base.Hooks),
LanguageFingerprint: coalesce(parsed.LanguageFingerprint, base.LanguageFingerprint),
Rhythm: coalesce(parsed.Rhythm, base.Rhythm),
Punctuation: coalesce(parsed.Punctuation, base.Punctuation),
ContentPatterns: coalesce(parsed.ContentPatterns, base.ContentPatterns),
KnowledgeTranslation: coalesce(parsed.KnowledgeTranslation, base.KnowledgeTranslation),
CtaStyle: coalesce(parsed.CtaStyle, base.CtaStyle),
Examples: coalesce(parsed.Examples, base.Examples),
Avoid: coalesce(parsed.Avoid, base.Avoid),
}
// reject empty-shell identity like "有趣人設 · 對標 @x"
if strings.Contains(draft.Identity, "· 對標 @") || strings.Contains(draft.Identity, "· 对标 @") {
if parsed.Tone != "" {
draft.Identity = "像 @" + username + " 的發文者:" + parsed.Tone
}
}
// 補齊仍空的 draft 欄位(規則底稿)
if draft.Audience == "" {
draft.Audience = "同溫層、會在 Threads 求經驗的人"
}
if draft.Hooks == "" {
draft.Hooks = pickHooks(strings.Join(samples, "\n"))
}
if draft.LanguageFingerprint == "" {
draft.LanguageFingerprint = "短句、口語、少成語"
}
if draft.Rhythm == "" {
draft.Rhythm = "24 個短段落,段間空行"
}
if draft.Punctuation == "" {
draft.Punctuation = "逗號與問號為主"
}
if draft.ContentPatterns == "" {
draft.ContentPatterns = "情境 → 感受或觀察 → 輕問一句"
}
if draft.KnowledgeTranslation == "" {
draft.KnowledgeTranslation = "先講使用情境,再講重點"
}
if draft.CtaStyle == "" {
draft.CtaStyle = "自然邀留言,不命令"
}
if draft.Avoid == "" {
draft.Avoid = "硬銷、客服腔、AI 腔"
}
if draft.Examples == "" && len(samples) > 0 {
r := []rune(samples[0])
if len(r) > 80 {
draft.Examples = string(r[:80])
} else {
draft.Examples = samples[0]
}
}
dims := collectDimsFromAI(parsed)
finalizePersonaAnalysis(p, draft, samples, source, username, sourceLabel, parsed.Brief, parsed.Name, dims)
return true
}