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 建議 80~220 字。",
|
|
|
|
|
|
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
|
|
|
|
}
|