thread-master/backend/internal/library/matrix/generate.go

290 lines
7.5 KiB
Go
Raw Permalink Normal View History

2026-06-26 08:37:04 +00:00
package matrix
import (
2026-06-28 08:28:42 +00:00
"context"
2026-06-26 08:37:04 +00:00
"encoding/json"
"fmt"
"regexp"
"strings"
libprompt "haixun-backend/internal/library/prompt"
"haixun-backend/internal/library/threadspost"
2026-06-28 08:28:42 +00:00
domai "haixun-backend/internal/model/ai/domain/usecase"
2026-06-26 08:37:04 +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
}
2026-06-28 08:28:42 +00:00
var codeFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
2026-06-26 08:37:04 +00:00
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")
}
2026-06-28 08:28:42 +00:00
type TextGenerator interface {
GenerateText(ctx context.Context, req domai.GenerateRequest) (*domai.GenerateResult, error)
}
// CopyMatrixGenerateRequest tunes token budget for multi-row JSON matrix output.
func CopyMatrixGenerateRequest(base domai.GenerateRequest, count int) domai.GenerateRequest {
if count <= 0 {
count = 5
}
temp := 0.45
tokens := 8192 + count*1024
if tokens > 16384 {
tokens = 16384
}
base.Temperature = &temp
base.MaxTokens = &tokens
return base
}
func MatrixRetryUserPrompt(count int) string {
if count <= 0 {
count = 5
}
return fmt.Sprintf(
"上一則回覆的 JSON 不完整或無法解析。請只回傳單一 JSON 物件 {\"rows\":[...]},共 %d 篇,不要用 markdown 程式碼區塊、不要加說明文字。\n"+
"每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale。\n"+
"reference_notes 與 rationale 各不超過 40 字text 建議 80220 字。",
count,
)
}
// GenerateCopyOutput calls the LLM and parses matrix JSON, with one compact-json retry.
func GenerateCopyOutput(
ctx context.Context,
ai TextGenerator,
req domai.GenerateRequest,
count int,
) (GenerateResult, error) {
genReq := CopyMatrixGenerateRequest(req, count)
result, err := ai.GenerateText(ctx, genReq)
if err != nil {
return GenerateResult{}, err
}
parsed, parseErr := ParseGenerateOutput(result.Text)
if parseErr == nil {
if count > 0 && len(parsed.Rows) < count {
parseErr = fmt.Errorf("matrix truncated: got %d rows, want %d", len(parsed.Rows), count)
} else {
return parsed, nil
}
}
retryReq := CopyMatrixGenerateRequest(domai.GenerateRequest{
Provider: req.Provider,
Model: req.Model,
Credential: req.Credential,
System: req.System,
Messages: append(
append([]domai.Message{}, req.Messages...),
domai.Message{Role: "assistant", Content: result.Text},
domai.Message{Role: "user", Content: MatrixRetryUserPrompt(count)},
),
}, count)
retryResult, retryErr := ai.GenerateText(ctx, retryReq)
if retryErr != nil {
return GenerateResult{}, fmt.Errorf("matrix retry failed: %w (first parse: %v)", retryErr, parseErr)
}
retryParsed, retryParseErr := ParseGenerateOutput(retryResult.Text)
if retryParseErr != nil {
return GenerateResult{}, retryParseErr
}
if count > 0 && len(retryParsed.Rows) < count {
return GenerateResult{}, fmt.Errorf("matrix truncated after retry: got %d rows, want %d", len(retryParsed.Rows), count)
}
return retryParsed, nil
}
2026-06-26 08:37:04 +00:00
func ParseGenerateOutput(raw string) (GenerateResult, error) {
payload, err := extractJSONObject(raw)
if err != nil {
return GenerateResult{}, err
}
2026-06-28 08:28:42 +00:00
out, err := decodeGenerateResult(payload)
if err == nil {
return normalizeGenerateResult(out)
}
repaired, repairErr := repairTruncatedJSONObject(payload)
if repairErr != nil {
return GenerateResult{}, err
}
out, err = decodeGenerateResult(repaired)
if err != nil {
return GenerateResult{}, fmt.Errorf("parse matrix json: %w", err)
}
return normalizeGenerateResult(out)
}
func decodeGenerateResult(payload []byte) (GenerateResult, error) {
2026-06-26 08:37:04 +00:00
var out GenerateResult
if err := json.Unmarshal(payload, &out); err != nil {
2026-06-28 08:28:42 +00:00
return GenerateResult{}, err
2026-06-26 08:37:04 +00:00
}
2026-06-28 08:28:42 +00:00
return out, nil
}
func normalizeGenerateResult(out GenerateResult) (GenerateResult, error) {
2026-06-26 08:37:04 +00:00
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)
if len(runes) > threadspost.MaxPublishRunes {
return string(runes[:threadspost.MaxPublishRunes])
}
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, "{")
2026-06-28 08:28:42 +00:00
if start < 0 {
return nil, fmt.Errorf("matrix output missing json object")
}
slice := raw[start:]
end := strings.LastIndex(slice, "}")
if end <= 0 {
return []byte(slice), nil
}
return []byte(slice[:end+1]), nil
}
func repairTruncatedJSONObject(payload []byte) ([]byte, error) {
if len(payload) == 0 || payload[0] != '{' {
2026-06-26 08:37:04 +00:00
return nil, fmt.Errorf("matrix output missing json object")
}
2026-06-28 08:28:42 +00:00
stack := make([]byte, 0, 8)
inString := false
escaped := false
for _, b := range payload {
if inString {
if escaped {
escaped = false
continue
}
if b == '\\' {
escaped = true
continue
}
if b == '"' {
inString = false
}
continue
}
switch b {
case '"':
inString = true
case '{':
stack = append(stack, '}')
case '[':
stack = append(stack, ']')
case '}', ']':
if len(stack) > 0 && stack[len(stack)-1] == b {
stack = stack[:len(stack)-1]
}
}
}
repaired := append([]byte(nil), payload...)
if inString {
repaired = append(repaired, '"')
}
for i := len(stack) - 1; i >= 0; i-- {
repaired = append(repaired, stack[i])
}
return repaired, nil
2026-06-26 08:37:04 +00:00
}