thread-master/old/backend/internal/library/style8d/analyze.go

568 lines
16 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 (
"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
}