391 lines
11 KiB
Go
391 lines
11 KiB
Go
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
|
||
} |