126 lines
4.1 KiB
Go
126 lines
4.1 KiB
Go
package viral
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"regexp"
|
||
"strings"
|
||
|
||
"haixun-backend/internal/library/copyvoice"
|
||
"haixun-backend/internal/library/threadspost"
|
||
)
|
||
|
||
var codeFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```")
|
||
|
||
type ReplicateInput struct {
|
||
TopicLabel string
|
||
TopicBrief string
|
||
Persona string
|
||
StyleProfile string
|
||
OriginalText string
|
||
AuthorName string
|
||
StructureAnalysis string
|
||
NarrativeSeed string
|
||
}
|
||
|
||
type ReplicateResult struct {
|
||
Angle string `json:"angle"`
|
||
Hook string `json:"hook"`
|
||
Text string `json:"text"`
|
||
Rationale string `json:"rationale"`
|
||
StructureNotes string `json:"structureNotes"`
|
||
}
|
||
|
||
func BuildSystemPrompt() string {
|
||
return strings.TrimSpace(`你是 Threads / Instagram 創作者。讀完參考爆文後,用指定人設的語言指紋寫一篇全新貼文。
|
||
|
||
` + copyvoice.SystemRules() + `
|
||
|
||
輸出:
|
||
- text 主文 ≤ 500 字(Threads API 硬上限,含 #話題標籤)
|
||
- 參考原文只學情緒力度、資訊密度與節奏;最後必須換成人設會講出口的話
|
||
- 篇幅、行數、標點與換行優先遵守人設語言指紋;沒有指紋時才貼近參考原文
|
||
- text 排版要像真人手機發文:可使用自然標點、空行、列點與少量 emoji;不要完全無標點,也不要每句硬換行,除非人設樣本就是這樣
|
||
- 只回傳一個 JSON 物件,欄位:angle, hook, text, rationale, structureNotes`)
|
||
}
|
||
|
||
func BuildUserPrompt(input ReplicateInput) string {
|
||
var b strings.Builder
|
||
b.WriteString("主題:")
|
||
b.WriteString(strings.TrimSpace(input.TopicLabel))
|
||
b.WriteString("\n")
|
||
if brief := strings.TrimSpace(input.TopicBrief); brief != "" {
|
||
b.WriteString("Brief:")
|
||
b.WriteString(brief)
|
||
b.WriteString("\n")
|
||
}
|
||
if persona := strings.TrimSpace(input.Persona); persona != "" {
|
||
b.WriteString("\n人設定位、語言指紋與禁忌(最高優先):\n")
|
||
b.WriteString(persona)
|
||
b.WriteString("\n")
|
||
b.WriteString(copyvoice.PersonaCalibrationNote())
|
||
b.WriteString("\n")
|
||
}
|
||
if style := strings.TrimSpace(input.StyleProfile); style != "" {
|
||
b.WriteString("\n8D 風格策略:\n")
|
||
b.WriteString(style)
|
||
b.WriteString("\n")
|
||
}
|
||
if analysis := strings.TrimSpace(input.StructureAnalysis); analysis != "" {
|
||
b.WriteString("\n敘事分析(只學故事感,不要套句型或段落公式):\n")
|
||
b.WriteString(analysis)
|
||
b.WriteString("\n")
|
||
}
|
||
author := strings.TrimSpace(input.AuthorName)
|
||
if author == "" {
|
||
author = "匿名"
|
||
}
|
||
original := strings.TrimSpace(input.OriginalText)
|
||
if note := threadspost.MimicLengthGuidance(original); note != "" {
|
||
b.WriteString("\n")
|
||
b.WriteString(note)
|
||
b.WriteString("\n")
|
||
}
|
||
b.WriteString("\n原文參考(@")
|
||
b.WriteString(author)
|
||
b.WriteString(",只感受情緒、敘事與篇幅,禁止抄句型):\n")
|
||
b.WriteString(original)
|
||
b.WriteString("\n\n本次敘事切入:")
|
||
b.WriteString(copyvoice.NarrativeLens(input.NarrativeSeed))
|
||
b.WriteString("\n\n請產出一篇可發布的全新貼文 JSON。hook 欄位只放內部分析,不要把模板句型寫進 text。")
|
||
return b.String()
|
||
}
|
||
|
||
func ParseReplicateOutput(raw string) (ReplicateResult, error) {
|
||
payload, err := extractJSONObject(raw)
|
||
if err != nil {
|
||
return ReplicateResult{}, err
|
||
}
|
||
var out ReplicateResult
|
||
if err := json.Unmarshal(payload, &out); err != nil {
|
||
return ReplicateResult{}, fmt.Errorf("parse viral replica json: %w", err)
|
||
}
|
||
out.Text = threadspost.FormatDraftText(out.Text)
|
||
if out.Text == "" {
|
||
return ReplicateResult{}, fmt.Errorf("replica text missing")
|
||
}
|
||
out.Angle = strings.TrimSpace(out.Angle)
|
||
out.Hook = strings.TrimSpace(out.Hook)
|
||
out.Rationale = strings.TrimSpace(out.Rationale)
|
||
out.StructureNotes = strings.TrimSpace(out.StructureNotes)
|
||
return out, nil
|
||
}
|
||
|
||
func extractJSONObject(raw string) ([]byte, error) {
|
||
raw = strings.TrimSpace(raw)
|
||
if m := codeFenceRE.FindStringSubmatch(raw); len(m) == 2 {
|
||
raw = strings.TrimSpace(m[1])
|
||
}
|
||
start := strings.Index(raw, "{")
|
||
end := strings.LastIndex(raw, "}")
|
||
if start < 0 || end <= start {
|
||
return nil, fmt.Errorf("replica output missing json object")
|
||
}
|
||
return []byte(raw[start : end+1]), nil
|
||
}
|