haixunMaster/haixun-backend/internal/library/style8d/analyze.go

392 lines
11 KiB
Go
Raw Normal View History

2026-06-23 16:55:10 +00:00
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 {
2026-06-24 10:02:42 +00:00
Summary string `json:"summary"`
Evidence []string `json:"evidence"`
2026-06-23 16:55:10 +00:00
}
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 {
2026-06-24 10:02:42 +00:00
MeasuredPosts int `json:"measuredPosts"`
MedianInteractions int `json:"medianInteractions"`
AverageInteractions int `json:"averageInteractions"`
PostsAboveThreshold int `json:"postsAboveThreshold"`
Threshold int `json:"threshold"`
Verdict string `json:"verdict"`
2026-06-23 16:55:10 +00:00
}
type StoredProfile struct {
2026-06-24 10:02:42 +00:00
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"`
2026-06-23 16:55:10 +00:00
}
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{
2026-06-24 10:02:42 +00:00
Username: strings.TrimPrefix(strings.TrimSpace(username), "@"),
AnalyzedAt: time.Now().UTC().Format(time.RFC3339),
PostCount: len(posts),
Engagement: EvaluateEngagement(posts, 10),
2026-06-23 16:55:10 +00:00
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
2026-06-24 10:02:42 +00:00
}