package style8d import ( "bytes" "encoding/json" "fmt" "math" "regexp" "strings" "time" "haixun-backend/internal/library/llmjson" ) type Post struct { Text string `json:"text"` Permalink string `json:"permalink,omitempty"` LikeCount int `json:"like_count,omitempty"` ReplyCount int `json:"reply_count,omitempty"` } type Dimension struct { Summary string `json:"summary"` Evidence []string `json:"evidence"` } type PersonaDraft struct { Identity string `json:"identity"` Tone string `json:"tone"` Audience string `json:"audience"` Hooks string `json:"hooks"` LanguageFingerprint string `json:"languageFingerprint"` Rhythm string `json:"rhythm"` Punctuation string `json:"punctuation"` ContentPatterns string `json:"contentPatterns"` KnowledgeTranslation string `json:"knowledgeTranslation"` CTAStyle string `json:"ctaStyle"` Examples string `json:"examples"` Avoid string `json:"avoid"` } type LLMOutput struct { D1Tone Dimension `json:"d1Tone"` D2Structure Dimension `json:"d2Structure"` D3Interaction Dimension `json:"d3Interaction"` D4Topics Dimension `json:"d4Topics"` D5Rhythm Dimension `json:"d5Rhythm"` D6Visual Dimension `json:"d6Visual"` D7Conversion Dimension `json:"d7Conversion"` D8Risk Dimension `json:"d8Risk"` PersonaDraft PersonaDraft `json:"personaDraft"` } type Engagement struct { MeasuredPosts int `json:"measuredPosts"` MedianInteractions int `json:"medianInteractions"` AverageInteractions int `json:"averageInteractions"` PostsAboveThreshold int `json:"postsAboveThreshold"` Threshold int `json:"threshold"` Verdict string `json:"verdict"` } type StoredProfile struct { Username string `json:"username"` SourceType string `json:"sourceType,omitempty"` SourceLabel string `json:"sourceLabel,omitempty"` AnalyzedAt string `json:"analyzedAt"` PostCount int `json:"postCount"` Engagement Engagement `json:"engagement"` SamplePosts []Post `json:"samplePosts,omitempty"` Analysis map[string]Dimension `json:"analysis"` PersonaDraft string `json:"personaDraft"` } const ( SourceTypeBenchmark = "benchmark" SourceTypeManual = "manual" maxReferenceTexts = 12 maxReferenceRunes = 8000 minReferenceRunes = 10 ) func ManualStyleBenchmark(label string) string { label = strings.TrimSpace(label) if label == "" { label = "手動貼文" } return "manual:" + label } func NormalizeReferenceTexts(referenceTexts []string, rawText string) ([]string, error) { texts := make([]string, 0, len(referenceTexts)) for _, item := range referenceTexts { item = strings.TrimSpace(item) if item != "" { texts = append(texts, item) } } if len(texts) == 0 { rawText = strings.TrimSpace(rawText) if rawText != "" { for _, chunk := range splitReferenceRawText(rawText) { chunk = strings.TrimSpace(chunk) if chunk != "" { texts = append(texts, chunk) } } } } if len(texts) == 0 { return nil, fmt.Errorf("至少需要 1 段參考文字") } if len(texts) > maxReferenceTexts { return nil, fmt.Errorf("參考文字最多 %d 段", maxReferenceTexts) } totalRunes := 0 out := make([]string, 0, len(texts)) for _, item := range texts { runes := []rune(item) if len(runes) < minReferenceRunes { return nil, fmt.Errorf("每段參考文字至少 %d 字", minReferenceRunes) } totalRunes += len(runes) if totalRunes > maxReferenceRunes { return nil, fmt.Errorf("參考文字總長度不可超過 %d 字", maxReferenceRunes) } out = append(out, item) } return out, nil } func TextsToPosts(texts []string) []Post { posts := make([]Post, 0, len(texts)) for _, text := range texts { posts = append(posts, Post{Text: text}) } return posts } func splitReferenceRawText(raw string) []string { lines := strings.Split(raw, "\n") chunks := make([]string, 0, 4) var b strings.Builder flush := func() { if b.Len() == 0 { return } chunks = append(chunks, b.String()) b.Reset() } for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "---" || trimmed == "———" || trimmed == "___" { flush() continue } if b.Len() > 0 { b.WriteByte('\n') } b.WriteString(line) } flush() if len(chunks) == 0 { return []string{strings.TrimSpace(raw)} } return chunks } func BuildUserPromptFromTexts(sourceLabel string, brief string, posts []Post) string { label := strings.TrimSpace(sourceLabel) if label == "" { label = "手動貼文" } var b strings.Builder fmt.Fprintf(&b, "文字樣本來源:%s\n", label) if brief := strings.TrimSpace(brief); brief != "" { fmt.Fprintf(&b, "人設定位補充:%s\n", brief) } b.WriteString("參考文字樣本(無互動數據,請只根據文字風格歸納):\n\n") limit := len(posts) if limit > maxReferenceTexts { limit = maxReferenceTexts } for i := 0; i < limit; i++ { text := posts[i].Text if len(text) > 500 { text = text[:500] } fmt.Fprintf(&b, "[%d]\n%s\n\n", i+1, text) } return b.String() } func BuildUserPrompt(username string, posts []Post) string { var b strings.Builder fmt.Fprintf(&b, "對標帳號:@%s\n近期貼文樣本:\n\n", strings.TrimPrefix(username, "@")) limit := len(posts) if limit > 12 { limit = 12 } for i := 0; i < limit; i++ { post := posts[i] text := post.Text if len(text) > 500 { text = text[:500] } fmt.Fprintf(&b, "[%d] %d讚 %d回覆\n%s\n\n", i+1, post.LikeCount, post.ReplyCount, text) } return b.String() } func EvaluateEngagement(posts []Post, threshold int) Engagement { if threshold <= 0 { threshold = 10 } values := make([]float64, 0, len(posts)) for _, post := range posts { score := float64(post.LikeCount) + float64(post.ReplyCount)*2 if score > 0 { values = append(values, score) } } median := 0.0 if len(values) > 0 { sorted := append([]float64(nil), values...) for i := 0; i < len(sorted); i++ { for j := i + 1; j < len(sorted); j++ { if sorted[j] < sorted[i] { sorted[i], sorted[j] = sorted[j], sorted[i] } } } mid := len(sorted) / 2 if len(sorted)%2 == 1 { median = sorted[mid] } else { median = (sorted[mid-1] + sorted[mid]) / 2 } } avg := 0.0 for _, v := range values { avg += v } if len(values) > 0 { avg /= float64(len(values)) } above := 0 for _, v := range values { if v >= float64(threshold) { above++ } } verdict := "unknown" if len(values) >= 3 { if median >= float64(threshold) && above >= 3 { verdict = "strong" } else { verdict = "usable" } } return Engagement{ MeasuredPosts: len(values), MedianInteractions: int(math.Round(median)), AverageInteractions: int(math.Round(avg)), PostsAboveThreshold: above, Threshold: threshold, Verdict: verdict, } } func SerializePersonaDraft(draft PersonaDraft) string { parts := []string{ "【我是誰】\n" + strings.TrimSpace(draft.Identity), "【語氣】\n" + strings.TrimSpace(draft.Tone), "【對誰說】\n" + strings.TrimSpace(draft.Audience), "【開場習慣】\n" + strings.TrimSpace(draft.Hooks), "【語言指紋】\n" + strings.TrimSpace(draft.LanguageFingerprint), "【段落節奏】\n" + strings.TrimSpace(draft.Rhythm), "【標點與換行】\n" + strings.TrimSpace(draft.Punctuation), "【內容型態】\n" + strings.TrimSpace(draft.ContentPatterns), "【知識轉譯】\n" + strings.TrimSpace(draft.KnowledgeTranslation), "【互動與 CTA】\n" + strings.TrimSpace(draft.CTAStyle), "【代表句範例】\n" + strings.TrimSpace(draft.Examples), "【避免】\n" + strings.TrimSpace(draft.Avoid), } return strings.Join(parts, "\n\n") } func ParseLLMOutput(raw string) (LLMOutput, error) { payload, err := extractJSONObject(raw) if err != nil { return LLMOutput{}, err } var root map[string]json.RawMessage if err := llmjson.Unmarshal(payload, &root); err != nil { return LLMOutput{}, fmt.Errorf("parse style8d json: %w", err) } for _, key := range []string{"analysis", "style8d", "style8D", "result", "data", "output"} { if nested, ok := root[key]; ok { var merged map[string]json.RawMessage if err := llmjson.Unmarshal(nested, &merged); err == nil { for k, v := range merged { if _, exists := root[k]; !exists { root[k] = v } } } } } out := LLMOutput{ D1Tone: parseDimension(root, "d1Tone", "d1", "D1", "tone"), D2Structure: parseDimension(root, "d2Structure", "d2", "D2", "structure"), D3Interaction: parseDimension(root, "d3Interaction", "d3", "D3", "interaction"), D4Topics: parseDimension(root, "d4Topics", "d4", "D4", "topics"), D5Rhythm: parseDimension(root, "d5Rhythm", "d5", "D5", "rhythm"), D6Visual: parseDimension(root, "d6Visual", "d6", "D6", "visual"), D7Conversion: parseDimension(root, "d7Conversion", "d7", "D7", "conversion"), D8Risk: parseDimension(root, "d8Risk", "d8", "D8", "risk"), PersonaDraft: parsePersonaDraft(root), } if err := validateOutput(out); err != nil { return LLMOutput{}, err } return out, nil } func buildSamplePosts(posts []Post) []Post { limit := len(posts) if limit > 12 { limit = 12 } out := make([]Post, 0, limit) for i := 0; i < limit; i++ { post := posts[i] text := strings.TrimSpace(post.Text) if len(text) > 500 { text = text[:500] } out = append(out, Post{ Text: text, Permalink: strings.TrimSpace(post.Permalink), LikeCount: post.LikeCount, ReplyCount: post.ReplyCount, }) } return out } func BuildStoredProfile(username string, posts []Post, out LLMOutput) StoredProfile { profile := baseStoredProfile(posts, out) profile.Username = strings.TrimPrefix(strings.TrimSpace(username), "@") profile.SourceType = SourceTypeBenchmark return profile } func BuildStoredProfileFromTexts(sourceLabel string, posts []Post, out LLMOutput) StoredProfile { label := strings.TrimSpace(sourceLabel) if label == "" { label = "手動貼文" } profile := baseStoredProfile(posts, out) profile.SourceType = SourceTypeManual profile.SourceLabel = label profile.Engagement.Verdict = "not_applicable" return profile } func baseStoredProfile(posts []Post, out LLMOutput) StoredProfile { return StoredProfile{ AnalyzedAt: time.Now().UTC().Format(time.RFC3339), PostCount: len(posts), Engagement: EvaluateEngagement(posts, 10), SamplePosts: buildSamplePosts(posts), Analysis: map[string]Dimension{ "d1Tone": trimDimension(out.D1Tone), "d2Structure": trimDimension(out.D2Structure), "d3Interaction": trimDimension(out.D3Interaction), "d4Topics": trimDimension(out.D4Topics), "d5Rhythm": trimDimension(out.D5Rhythm), "d6Visual": trimDimension(out.D6Visual), "d7Conversion": trimDimension(out.D7Conversion), "d8Risk": trimDimension(out.D8Risk), }, PersonaDraft: SerializePersonaDraft(out.PersonaDraft), } } func (p StoredProfile) JSON() (string, error) { raw, err := json.Marshal(p) if err != nil { return "", err } return string(raw), nil } func trimDimension(d Dimension) Dimension { d.Summary = strings.TrimSpace(d.Summary) if len(d.Evidence) > 4 { d.Evidence = d.Evidence[:4] } out := make([]string, 0, len(d.Evidence)) for _, item := range d.Evidence { item = strings.TrimSpace(item) if item != "" { out = append(out, item) } } d.Evidence = out return d } func validateOutput(out LLMOutput) error { missing := []string{} for name, dim := range map[string]Dimension{ "D1": out.D1Tone, "D2": out.D2Structure, "D3": out.D3Interaction, "D4": out.D4Topics, "D5": out.D5Rhythm, "D6": out.D6Visual, "D7": out.D7Conversion, "D8": out.D8Risk, } { if strings.TrimSpace(dim.Summary) == "" { missing = append(missing, name) } } if len(missing) > 0 { return fmt.Errorf("LLM 回傳缺少維度摘要:%s", strings.Join(missing, ", ")) } if strings.TrimSpace(out.PersonaDraft.Identity) == "" && strings.TrimSpace(out.PersonaDraft.Tone) == "" { return fmt.Errorf("LLM 回傳缺少 personaDraft") } return nil } var codeFenceRE = regexp.MustCompile("(?s)^```(?:json)?\\s*(.*?)\\s*```$") func extractJSONObject(raw string) ([]byte, error) { text := strings.TrimSpace(raw) if text == "" { return nil, fmt.Errorf("empty LLM response") } if m := codeFenceRE.FindStringSubmatch(text); len(m) == 2 { text = strings.TrimSpace(m[1]) } start := strings.Index(text, "{") end := strings.LastIndex(text, "}") if start < 0 || end <= start { return nil, fmt.Errorf("LLM response does not contain JSON object") } return repairTruncatedJSON([]byte(text[start : end+1])), nil } // repairTruncatedJSON adds missing closing braces/brackets when the LLM output // was cut off mid-JSON. Returns nil if no repair was needed or possible. func repairTruncatedJSON(raw []byte) []byte { opens := bytes.Count(raw, []byte("{")) closes := bytes.Count(raw, []byte("}")) if closes < opens { raw = append(raw, bytes.Repeat([]byte("}"), opens-closes)...) } bracketOpens := bytes.Count(raw, []byte("[")) bracketCloses := bytes.Count(raw, []byte("]")) if bracketCloses < bracketOpens { raw = append(raw, bytes.Repeat([]byte("]"), bracketOpens-bracketCloses)...) } return raw } func parseDimension(root map[string]json.RawMessage, keys ...string) Dimension { for _, key := range keys { if raw, ok := root[key]; ok { if dim := decodeDimension(raw); strings.TrimSpace(dim.Summary) != "" { return dim } } } return Dimension{} } func decodeDimension(raw json.RawMessage) Dimension { var asString string if err := llmjson.Unmarshal(raw, &asString); err == nil && strings.TrimSpace(asString) != "" { return Dimension{Summary: strings.TrimSpace(asString)} } var obj map[string]json.RawMessage if err := llmjson.Unmarshal(raw, &obj); err != nil { return Dimension{} } summary := firstString(obj, "summary", "description", "analysis", "conclusion") evidence := firstStringSlice(obj, "evidence", "examples", "quotes", "proof") return Dimension{Summary: summary, Evidence: evidence} } func parsePersonaDraft(root map[string]json.RawMessage) PersonaDraft { for _, key := range []string{"personaDraft", "persona", "persona_draft", "voiceProfile"} { raw, ok := root[key] if !ok { continue } var asString string if err := llmjson.Unmarshal(raw, &asString); err == nil && strings.TrimSpace(asString) != "" { return PersonaDraft{Identity: strings.TrimSpace(asString)} } var obj map[string]json.RawMessage if err := llmjson.Unmarshal(raw, &obj); err != nil { continue } return PersonaDraft{ Identity: firstString(obj, "identity", "role"), Tone: firstString(obj, "tone", "voice"), Audience: firstString(obj, "audience", "targetAudience"), Hooks: firstString(obj, "hooks", "openings"), LanguageFingerprint: firstString(obj, "languageFingerprint", "language_fingerprint", "signaturePhrases", "signature_phrases"), Rhythm: firstString(obj, "rhythm", "paragraphRhythm", "paragraph_rhythm"), Punctuation: firstString(obj, "punctuation", "visualSyntax", "visual_syntax"), ContentPatterns: firstString(obj, "contentPatterns", "content_patterns", "structures"), KnowledgeTranslation: firstString(obj, "knowledgeTranslation", "knowledge_translation", "researchTranslation", "research_translation"), CTAStyle: firstString(obj, "ctaStyle", "cta_style", "conversionStyle", "conversion_style"), Examples: firstString(obj, "examples", "sample"), Avoid: firstString(obj, "avoid", "risks"), } } return PersonaDraft{} } func firstString(obj map[string]json.RawMessage, keys ...string) string { for _, key := range keys { raw, ok := obj[key] if !ok { continue } var value string if err := llmjson.Unmarshal(raw, &value); err == nil { value = strings.TrimSpace(value) if value != "" { return value } } } return "" } func firstStringSlice(obj map[string]json.RawMessage, keys ...string) []string { for _, key := range keys { raw, ok := obj[key] if !ok { continue } var values []string if err := llmjson.Unmarshal(raw, &values); err == nil { out := make([]string, 0, len(values)) for _, item := range values { item = strings.TrimSpace(item) if item != "" { out = append(out, item) } } if len(out) > 0 { return out } } var single string if err := llmjson.Unmarshal(raw, &single); err == nil { single = strings.TrimSpace(single) if single != "" { return []string{single} } } } return nil }