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