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

392 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}