thread-master/backend/internal/library/formula/generate_from_formula.go

149 lines
5.7 KiB
Go
Raw Normal View History

package formula
import (
"context"
"fmt"
"regexp"
"strings"
"haixun-backend/internal/library/copyvoice"
libllmjson "haixun-backend/internal/library/llmjson"
"haixun-backend/internal/library/threadspost"
domai "haixun-backend/internal/model/ai/domain/usecase"
cfdom "haixun-backend/internal/model/content_formula/domain/usecase"
)
var generatedDraftFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```")
type GenerateInput struct {
Topic string
Brief string
PersonaBlock string
ResearchNotes string
Formula cfdom.FormulaSummary
}
type GeneratedDraft struct {
Text string `json:"text"`
TopicTag string `json:"topic_tag"`
Hook string `json:"hook"`
Angle string `json:"angle"`
Rationale string `json:"rationale"`
StructureNotes string `json:"structure_notes"`
}
func BuildGenerateSystemPrompt() string {
return strings.TrimSpace(`你是 Threads / Instagram 貼文代筆寫出符合人設語言指紋可直接發布的完整新貼文不是解釋公式也不是填空模板
` + copyvoice.SystemRules() + `
規則
- 用台灣繁體中文符合人設語氣
- 正文素材以主題補充周邊知識參考為準人設不得改變題材
- 人設定位決定誰在說語言指紋決定怎麼說必須遵守段落節奏標點換行知識轉譯與 CTA 習慣
- text 必須是一篇完整自然的 Threads 貼文可直接複製發布
- 不可以出現公式模板Hook情境轉折觀點收尾[括號]步驟結構等後台分析字眼
- 不可以輸出填空骨架不可以照抄參考原文
- 有參考原文時只學資訊密度情緒力度與可用結構最後仍要換成人設會講出口的話
- text 排版像真人手機發文可使用自然標點空行列點與少量 emoji不要完全無標點也不要每句硬換行除非人設樣本就是這樣
- topic_tag 請產生 1 個適合 Threads 主題標籤的短詞不含 #例如AI工具內容創作職場
- 只回傳 JSONtext, topic_tag, hook, angle, rationale, structure_notes繁體中文`)
}
func BuildGenerateUserPrompt(in GenerateInput) string {
var b strings.Builder
b.WriteString("【主題】\n")
b.WriteString(strings.TrimSpace(in.Topic))
if brief := strings.TrimSpace(in.Brief); brief != "" {
b.WriteString("\n補充")
b.WriteString(brief)
}
b.WriteString("\n\n【人設定位、語言指紋與禁忌最高優先】\n")
b.WriteString(strings.TrimSpace(in.PersonaBlock))
b.WriteString("\n")
b.WriteString(copyvoice.PersonaCalibrationNote())
b.WriteString("\n\n【這篇爆文的故事感只感受情緒與敘事禁止照搬句型或填空】\n")
b.WriteString(strings.TrimSpace(in.Formula.Formula))
if structure := strings.TrimSpace(in.Formula.Structure); structure != "" {
b.WriteString("\n\n敘事推進參考勿照搬句型")
b.WriteString(structure)
}
if len(in.Formula.Avoid) > 0 {
b.WriteString("\n\n【避免】\n")
b.WriteString(strings.Join(in.Formula.Avoid, "\n"))
}
if notes := strings.TrimSpace(in.ResearchNotes); notes != "" {
b.WriteString("\n\n【周邊知識參考】\n")
b.WriteString(notes)
}
if src := strings.TrimSpace(in.Formula.SourcePostText); src != "" {
if note := threadspost.MimicLengthGuidance(src); note != "" {
b.WriteString("\n\n")
b.WriteString(note)
}
b.WriteString("\n\n【參考原文僅學結構與篇幅勿抄內容】\n")
b.WriteString(src)
}
b.WriteString("\n\n本次敘事切入")
b.WriteString(copyvoice.NarrativeLens(in.Topic))
b.WriteString("\n\n請產出一篇可直接發布的 Threads 貼文 JSON。text 欄位只能放貼文正文不要放分析、標題、公式、模板或填空欄位。topic_tag 請只放標籤文字,不要加 #。")
return b.String()
}
func GenerateDraft(ctx context.Context, ai domai.UseCase, req domai.GenerateRequest, in GenerateInput) (*GeneratedDraft, error) {
temp := 0.82
req.System = BuildGenerateSystemPrompt()
req.Messages = []domai.Message{{Role: "user", Content: BuildGenerateUserPrompt(in)}}
req.Temperature = &temp
out, err := ai.GenerateText(ctx, req)
if err != nil {
return nil, err
}
return ParseGeneratedDraft(out.Text)
}
func extractGeneratedDraftJSON(raw string) ([]byte, error) {
raw = strings.TrimSpace(raw)
if m := generatedDraftFenceRE.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("generate output missing json object")
}
return []byte(raw[start : end+1]), nil
}
func ParseGeneratedDraft(raw string) (*GeneratedDraft, error) {
payload, err := extractGeneratedDraftJSON(raw)
if err != nil {
return nil, err
}
var draft GeneratedDraft
if err := libllmjson.Unmarshal(payload, &draft); err != nil {
return nil, fmt.Errorf("parse generated draft: %w", err)
}
draft.Text = threadspost.FormatDraftText(draft.Text)
draft.TopicTag = normalizeTopicTag(draft.TopicTag)
if draft.Text == "" {
return nil, fmt.Errorf("generated draft missing text")
}
badTokens := []string{"[", "]", "【", "】", "Hook", "公式", "模板", "情境→", "轉折→", "觀點→", "收尾", "步驟"}
for _, token := range badTokens {
if strings.Contains(draft.Text, token) {
return nil, fmt.Errorf("generated draft looks like a template, not a publishable post")
}
}
return &draft, nil
}
func normalizeTopicTag(value string) string {
value = strings.TrimSpace(strings.TrimPrefix(value, "#"))
if len([]rune(value)) > 40 {
runes := []rune(value)
value = string(runes[:40])
}
return value
}