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

149 lines
5.7 KiB
Go
Raw Permalink 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 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
}