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 = "2~4 個短段落,段間空行" } 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": "簡短人設名稱(4~12 字,不要帳號)", "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 = "2~4 個短段落,段間空行" } 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 }