568 lines
16 KiB
Go
568 lines
16 KiB
Go
package style8d
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"math"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
|
||
"haixun-backend/internal/library/llmjson"
|
||
)
|
||
|
||
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"`
|
||
LanguageFingerprint string `json:"languageFingerprint"`
|
||
Rhythm string `json:"rhythm"`
|
||
Punctuation string `json:"punctuation"`
|
||
ContentPatterns string `json:"contentPatterns"`
|
||
KnowledgeTranslation string `json:"knowledgeTranslation"`
|
||
CTAStyle string `json:"ctaStyle"`
|
||
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"`
|
||
SourceType string `json:"sourceType,omitempty"`
|
||
SourceLabel string `json:"sourceLabel,omitempty"`
|
||
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"`
|
||
}
|
||
|
||
const (
|
||
SourceTypeBenchmark = "benchmark"
|
||
SourceTypeManual = "manual"
|
||
maxReferenceTexts = 12
|
||
maxReferenceRunes = 8000
|
||
minReferenceRunes = 10
|
||
)
|
||
|
||
func ManualStyleBenchmark(label string) string {
|
||
label = strings.TrimSpace(label)
|
||
if label == "" {
|
||
label = "手動貼文"
|
||
}
|
||
return "manual:" + label
|
||
}
|
||
|
||
func NormalizeReferenceTexts(referenceTexts []string, rawText string) ([]string, error) {
|
||
texts := make([]string, 0, len(referenceTexts))
|
||
for _, item := range referenceTexts {
|
||
item = strings.TrimSpace(item)
|
||
if item != "" {
|
||
texts = append(texts, item)
|
||
}
|
||
}
|
||
if len(texts) == 0 {
|
||
rawText = strings.TrimSpace(rawText)
|
||
if rawText != "" {
|
||
for _, chunk := range splitReferenceRawText(rawText) {
|
||
chunk = strings.TrimSpace(chunk)
|
||
if chunk != "" {
|
||
texts = append(texts, chunk)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if len(texts) == 0 {
|
||
return nil, fmt.Errorf("至少需要 1 段參考文字")
|
||
}
|
||
if len(texts) > maxReferenceTexts {
|
||
return nil, fmt.Errorf("參考文字最多 %d 段", maxReferenceTexts)
|
||
}
|
||
totalRunes := 0
|
||
out := make([]string, 0, len(texts))
|
||
for _, item := range texts {
|
||
runes := []rune(item)
|
||
if len(runes) < minReferenceRunes {
|
||
return nil, fmt.Errorf("每段參考文字至少 %d 字", minReferenceRunes)
|
||
}
|
||
totalRunes += len(runes)
|
||
if totalRunes > maxReferenceRunes {
|
||
return nil, fmt.Errorf("參考文字總長度不可超過 %d 字", maxReferenceRunes)
|
||
}
|
||
out = append(out, item)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func TextsToPosts(texts []string) []Post {
|
||
posts := make([]Post, 0, len(texts))
|
||
for _, text := range texts {
|
||
posts = append(posts, Post{Text: text})
|
||
}
|
||
return posts
|
||
}
|
||
|
||
func splitReferenceRawText(raw string) []string {
|
||
lines := strings.Split(raw, "\n")
|
||
chunks := make([]string, 0, 4)
|
||
var b strings.Builder
|
||
flush := func() {
|
||
if b.Len() == 0 {
|
||
return
|
||
}
|
||
chunks = append(chunks, b.String())
|
||
b.Reset()
|
||
}
|
||
for _, line := range lines {
|
||
trimmed := strings.TrimSpace(line)
|
||
if trimmed == "---" || trimmed == "———" || trimmed == "___" {
|
||
flush()
|
||
continue
|
||
}
|
||
if b.Len() > 0 {
|
||
b.WriteByte('\n')
|
||
}
|
||
b.WriteString(line)
|
||
}
|
||
flush()
|
||
if len(chunks) == 0 {
|
||
return []string{strings.TrimSpace(raw)}
|
||
}
|
||
return chunks
|
||
}
|
||
|
||
func BuildUserPromptFromTexts(sourceLabel string, brief string, posts []Post) string {
|
||
label := strings.TrimSpace(sourceLabel)
|
||
if label == "" {
|
||
label = "手動貼文"
|
||
}
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "文字樣本來源:%s\n", label)
|
||
if brief := strings.TrimSpace(brief); brief != "" {
|
||
fmt.Fprintf(&b, "人設定位補充:%s\n", brief)
|
||
}
|
||
b.WriteString("參考文字樣本(無互動數據,請只根據文字風格歸納):\n\n")
|
||
limit := len(posts)
|
||
if limit > maxReferenceTexts {
|
||
limit = maxReferenceTexts
|
||
}
|
||
for i := 0; i < limit; i++ {
|
||
text := posts[i].Text
|
||
if len(text) > 500 {
|
||
text = text[:500]
|
||
}
|
||
fmt.Fprintf(&b, "[%d]\n%s\n\n", i+1, text)
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
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.LanguageFingerprint),
|
||
"【段落節奏】\n" + strings.TrimSpace(draft.Rhythm),
|
||
"【標點與換行】\n" + strings.TrimSpace(draft.Punctuation),
|
||
"【內容型態】\n" + strings.TrimSpace(draft.ContentPatterns),
|
||
"【知識轉譯】\n" + strings.TrimSpace(draft.KnowledgeTranslation),
|
||
"【互動與 CTA】\n" + strings.TrimSpace(draft.CTAStyle),
|
||
"【代表句範例】\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 := llmjson.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 := llmjson.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 {
|
||
profile := baseStoredProfile(posts, out)
|
||
profile.Username = strings.TrimPrefix(strings.TrimSpace(username), "@")
|
||
profile.SourceType = SourceTypeBenchmark
|
||
return profile
|
||
}
|
||
|
||
func BuildStoredProfileFromTexts(sourceLabel string, posts []Post, out LLMOutput) StoredProfile {
|
||
label := strings.TrimSpace(sourceLabel)
|
||
if label == "" {
|
||
label = "手動貼文"
|
||
}
|
||
profile := baseStoredProfile(posts, out)
|
||
profile.SourceType = SourceTypeManual
|
||
profile.SourceLabel = label
|
||
profile.Engagement.Verdict = "not_applicable"
|
||
return profile
|
||
}
|
||
|
||
func baseStoredProfile(posts []Post, out LLMOutput) StoredProfile {
|
||
return StoredProfile{
|
||
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 repairTruncatedJSON([]byte(text[start : end+1])), nil
|
||
}
|
||
|
||
// repairTruncatedJSON adds missing closing braces/brackets when the LLM output
|
||
// was cut off mid-JSON. Returns nil if no repair was needed or possible.
|
||
func repairTruncatedJSON(raw []byte) []byte {
|
||
opens := bytes.Count(raw, []byte("{"))
|
||
closes := bytes.Count(raw, []byte("}"))
|
||
if closes < opens {
|
||
raw = append(raw, bytes.Repeat([]byte("}"), opens-closes)...)
|
||
}
|
||
bracketOpens := bytes.Count(raw, []byte("["))
|
||
bracketCloses := bytes.Count(raw, []byte("]"))
|
||
if bracketCloses < bracketOpens {
|
||
raw = append(raw, bytes.Repeat([]byte("]"), bracketOpens-bracketCloses)...)
|
||
}
|
||
return raw
|
||
}
|
||
|
||
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 := llmjson.Unmarshal(raw, &asString); err == nil && strings.TrimSpace(asString) != "" {
|
||
return Dimension{Summary: strings.TrimSpace(asString)}
|
||
}
|
||
var obj map[string]json.RawMessage
|
||
if err := llmjson.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 := llmjson.Unmarshal(raw, &asString); err == nil && strings.TrimSpace(asString) != "" {
|
||
return PersonaDraft{Identity: strings.TrimSpace(asString)}
|
||
}
|
||
var obj map[string]json.RawMessage
|
||
if err := llmjson.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"),
|
||
LanguageFingerprint: firstString(obj, "languageFingerprint", "language_fingerprint", "signaturePhrases", "signature_phrases"),
|
||
Rhythm: firstString(obj, "rhythm", "paragraphRhythm", "paragraph_rhythm"),
|
||
Punctuation: firstString(obj, "punctuation", "visualSyntax", "visual_syntax"),
|
||
ContentPatterns: firstString(obj, "contentPatterns", "content_patterns", "structures"),
|
||
KnowledgeTranslation: firstString(obj, "knowledgeTranslation", "knowledge_translation", "researchTranslation", "research_translation"),
|
||
CTAStyle: firstString(obj, "ctaStyle", "cta_style", "conversionStyle", "conversion_style"),
|
||
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 := llmjson.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 := llmjson.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 := llmjson.Unmarshal(raw, &single); err == nil {
|
||
single = strings.TrimSpace(single)
|
||
if single != "" {
|
||
return []string{single}
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|