2026-06-26 08:37:04 +00:00
|
|
|
|
package style8d
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-07-06 06:13:14 +00:00
|
|
|
|
"bytes"
|
2026-06-26 08:37:04 +00:00
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"math"
|
|
|
|
|
|
"regexp"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
2026-07-07 12:12:04 +00:00
|
|
|
|
|
|
|
|
|
|
"haixun-backend/internal/library/llmjson"
|
2026-06-26 08:37:04 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-07-06 06:13:14 +00:00
|
|
|
|
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"`
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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"`
|
2026-07-06 06:13:14 +00:00
|
|
|
|
SourceType string `json:"sourceType,omitempty"`
|
|
|
|
|
|
SourceLabel string `json:"sourceLabel,omitempty"`
|
2026-06-26 08:37:04 +00:00
|
|
|
|
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"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 06:13:14 +00:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 08:37:04 +00:00
|
|
|
|
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),
|
2026-07-06 06:13:14 +00:00
|
|
|
|
"【語言指紋】\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),
|
2026-06-26 08:37:04 +00:00
|
|
|
|
"【代表句範例】\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
|
2026-07-07 12:12:04 +00:00
|
|
|
|
if err := llmjson.Unmarshal(payload, &root); err != nil {
|
2026-06-26 08:37:04 +00:00
|
|
|
|
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
|
2026-07-07 12:12:04 +00:00
|
|
|
|
if err := llmjson.Unmarshal(nested, &merged); err == nil {
|
2026-06-26 08:37:04 +00:00
|
|
|
|
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 {
|
2026-07-06 06:13:14 +00:00
|
|
|
|
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 {
|
2026-06-26 08:37:04 +00:00
|
|
|
|
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")
|
|
|
|
|
|
}
|
2026-07-06 06:13:14 +00:00
|
|
|
|
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
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-07-07 12:12:04 +00:00
|
|
|
|
if err := llmjson.Unmarshal(raw, &asString); err == nil && strings.TrimSpace(asString) != "" {
|
2026-06-26 08:37:04 +00:00
|
|
|
|
return Dimension{Summary: strings.TrimSpace(asString)}
|
|
|
|
|
|
}
|
|
|
|
|
|
var obj map[string]json.RawMessage
|
2026-07-07 12:12:04 +00:00
|
|
|
|
if err := llmjson.Unmarshal(raw, &obj); err != nil {
|
2026-06-26 08:37:04 +00:00
|
|
|
|
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
|
2026-07-07 12:12:04 +00:00
|
|
|
|
if err := llmjson.Unmarshal(raw, &asString); err == nil && strings.TrimSpace(asString) != "" {
|
2026-06-26 08:37:04 +00:00
|
|
|
|
return PersonaDraft{Identity: strings.TrimSpace(asString)}
|
|
|
|
|
|
}
|
|
|
|
|
|
var obj map[string]json.RawMessage
|
2026-07-07 12:12:04 +00:00
|
|
|
|
if err := llmjson.Unmarshal(raw, &obj); err != nil {
|
2026-06-26 08:37:04 +00:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
return PersonaDraft{
|
2026-07-06 06:13:14 +00:00
|
|
|
|
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"),
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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
|
2026-07-07 12:12:04 +00:00
|
|
|
|
if err := llmjson.Unmarshal(raw, &value); err == nil {
|
2026-06-26 08:37:04 +00:00
|
|
|
|
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
|
2026-07-07 12:12:04 +00:00
|
|
|
|
if err := llmjson.Unmarshal(raw, &values); err == nil {
|
2026-06-26 08:37:04 +00:00
|
|
|
|
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
|
2026-07-07 12:12:04 +00:00
|
|
|
|
if err := llmjson.Unmarshal(raw, &single); err == nil {
|
2026-06-26 08:37:04 +00:00
|
|
|
|
single = strings.TrimSpace(single)
|
|
|
|
|
|
if single != "" {
|
|
|
|
|
|
return []string{single}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|