package style8d import ( "encoding/json" "fmt" "math" "regexp" "strings" "time" ) 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"` 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"` 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"` } 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.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 := json.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 := json.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 { return StoredProfile{ Username: strings.TrimPrefix(strings.TrimSpace(username), "@"), 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 []byte(text[start : end+1]), nil } 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 := json.Unmarshal(raw, &asString); err == nil && strings.TrimSpace(asString) != "" { return Dimension{Summary: strings.TrimSpace(asString)} } var obj map[string]json.RawMessage if err := json.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 := json.Unmarshal(raw, &asString); err == nil && strings.TrimSpace(asString) != "" { return PersonaDraft{Identity: strings.TrimSpace(asString)} } var obj map[string]json.RawMessage if err := json.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"), 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 := json.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 := json.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 := json.Unmarshal(raw, &single); err == nil { single = strings.TrimSpace(single) if single != "" { return []string{single} } } } return nil }