2026-06-24 10:02:42 +00:00
|
|
|
|
package matrix
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"regexp"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
|
|
libprompt "haixun-backend/internal/library/prompt"
|
2026-06-25 08:20:03 +00:00
|
|
|
|
"haixun-backend/internal/library/threadspost"
|
2026-06-24 10:02:42 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
type Row struct {
|
|
|
|
|
|
SortOrder int `json:"sort_order"`
|
|
|
|
|
|
SearchTag string `json:"search_tag"`
|
|
|
|
|
|
Angle string `json:"angle"`
|
|
|
|
|
|
Hook string `json:"hook"`
|
|
|
|
|
|
Text string `json:"text"`
|
|
|
|
|
|
ReferenceNotes string `json:"reference_notes"`
|
|
|
|
|
|
SourcePermalinks []string `json:"source_permalinks"`
|
|
|
|
|
|
Rationale string `json:"rationale"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type GenerateResult struct {
|
|
|
|
|
|
Rows []Row `json:"rows"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type MaterialPost struct {
|
|
|
|
|
|
SearchTag string
|
|
|
|
|
|
Author string
|
|
|
|
|
|
Text string
|
|
|
|
|
|
Permalink string
|
|
|
|
|
|
Priority string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type GenerateInput struct {
|
|
|
|
|
|
Persona string
|
|
|
|
|
|
TopicLabel string
|
|
|
|
|
|
AudienceBrief string
|
|
|
|
|
|
ProductBrief string
|
|
|
|
|
|
Posts []MaterialPost
|
|
|
|
|
|
Count int
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var codeFenceRE = regexp.MustCompile(`(?s)^` + "```(?:json)?\\s*(.*?)\\s*" + "```$")
|
|
|
|
|
|
|
|
|
|
|
|
func BuildUserPrompt(in GenerateInput) (string, error) {
|
|
|
|
|
|
count := in.Count
|
|
|
|
|
|
if count <= 0 {
|
|
|
|
|
|
count = 5
|
|
|
|
|
|
}
|
|
|
|
|
|
personaBlock := ""
|
|
|
|
|
|
if strings.TrimSpace(in.Persona) != "" {
|
|
|
|
|
|
personaBlock = "人設與語氣:\n" + strings.TrimSpace(in.Persona) + "\n"
|
|
|
|
|
|
}
|
|
|
|
|
|
audience := strings.TrimSpace(in.AudienceBrief)
|
|
|
|
|
|
if audience == "" {
|
|
|
|
|
|
audience = "(未指定)"
|
|
|
|
|
|
}
|
|
|
|
|
|
product := strings.TrimSpace(in.ProductBrief)
|
|
|
|
|
|
if product == "" {
|
|
|
|
|
|
product = "(尚未填寫)"
|
|
|
|
|
|
}
|
|
|
|
|
|
topic := strings.TrimSpace(in.TopicLabel)
|
|
|
|
|
|
if topic == "" {
|
|
|
|
|
|
topic = "未指定"
|
|
|
|
|
|
}
|
|
|
|
|
|
return libprompt.MatrixPlacementUser(map[string]string{
|
|
|
|
|
|
"persona_block": personaBlock,
|
|
|
|
|
|
"topic_label": topic,
|
|
|
|
|
|
"audience_line": audience,
|
|
|
|
|
|
"product_brief": product,
|
|
|
|
|
|
"post_count": fmt.Sprintf("%d", len(in.Posts)),
|
|
|
|
|
|
"materials_block": buildMaterialsBlock(in.Posts),
|
|
|
|
|
|
"count": fmt.Sprintf("%d", count),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func buildMaterialsBlock(posts []MaterialPost) string {
|
|
|
|
|
|
if len(posts) == 0 {
|
|
|
|
|
|
return "(無素材)"
|
|
|
|
|
|
}
|
|
|
|
|
|
lines := make([]string, 0, len(posts))
|
|
|
|
|
|
for i, post := range posts {
|
|
|
|
|
|
lines = append(lines, fmt.Sprintf(
|
|
|
|
|
|
"%d. [%s/%s] @%s\n%s\n連結:%s",
|
|
|
|
|
|
i+1,
|
|
|
|
|
|
strings.TrimSpace(post.Priority),
|
|
|
|
|
|
strings.TrimSpace(post.SearchTag),
|
|
|
|
|
|
strings.TrimSpace(post.Author),
|
|
|
|
|
|
strings.TrimSpace(post.Text),
|
|
|
|
|
|
strings.TrimSpace(post.Permalink),
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.Join(lines, "\n\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func ParseGenerateOutput(raw string) (GenerateResult, error) {
|
|
|
|
|
|
payload, err := extractJSONObject(raw)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return GenerateResult{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
var out GenerateResult
|
|
|
|
|
|
if err := json.Unmarshal(payload, &out); err != nil {
|
|
|
|
|
|
return GenerateResult{}, fmt.Errorf("parse matrix json: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(out.Rows) == 0 {
|
|
|
|
|
|
return GenerateResult{}, fmt.Errorf("matrix rows missing")
|
|
|
|
|
|
}
|
|
|
|
|
|
for i := range out.Rows {
|
|
|
|
|
|
out.Rows[i].Text = trimText(out.Rows[i].Text)
|
|
|
|
|
|
if out.Rows[i].Text == "" {
|
|
|
|
|
|
return GenerateResult{}, fmt.Errorf("matrix row %d empty", i+1)
|
|
|
|
|
|
}
|
|
|
|
|
|
if out.Rows[i].SortOrder <= 0 {
|
|
|
|
|
|
out.Rows[i].SortOrder = i + 1
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return out, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func trimText(text string) string {
|
|
|
|
|
|
text = strings.TrimSpace(text)
|
|
|
|
|
|
runes := []rune(text)
|
2026-06-25 08:20:03 +00:00
|
|
|
|
if len(runes) > threadspost.MaxPublishRunes {
|
|
|
|
|
|
return string(runes[:threadspost.MaxPublishRunes])
|
2026-06-24 10:02:42 +00:00
|
|
|
|
}
|
|
|
|
|
|
return text
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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("matrix output missing json object")
|
|
|
|
|
|
}
|
|
|
|
|
|
return []byte(raw[start : end+1]), nil
|
|
|
|
|
|
}
|