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

536 lines
16 KiB
Go
Raw Normal View History

2026-07-13 01:15:30 +00:00
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
}