536 lines
16 KiB
Go
536 lines
16 KiB
Go
|
|
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
|
|||
|
|
}
|