feat: add knowledge management functionality with CRUD operations and database schema
- Implemented KnowledgeHandler for managing knowledge groups and items, including listing, creating, updating, and deleting operations. - Added database migration scripts to create necessary tables for knowledge management, including knowledge_groups, knowledge_items, knowledge_parse_tasks, and knowledge_chunks_meta. - Introduced prompt_rule_knowledge_groups table to associate prompt rules with knowledge groups.
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
pdf "github.com/ledongthuc/pdf"
|
||||
"github.com/xuri/excelize/v2"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
const (
|
||||
maxKnowledgeFetchBytes = 5 << 20
|
||||
)
|
||||
|
||||
var whitespacePattern = regexp.MustCompile(`[ \t]+`)
|
||||
|
||||
func extractKnowledgeTextFromURL(ctx context.Context, rawURL string) (string, error) {
|
||||
data, contentType, err := fetchKnowledgeURLSnapshot(ctx, rawURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return extractKnowledgeTextFromSnapshot(resolveSnapshotFileName(rawURL, contentType), data)
|
||||
}
|
||||
|
||||
func fetchKnowledgeURLSnapshot(ctx context.Context, rawURL string) ([]byte, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimSpace(rawURL), nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "geo-knowledge-fetcher/1.0")
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("fetch url: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
return nil, "", fmt.Errorf("fetch url failed: status=%d", resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxKnowledgeFetchBytes))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("read url body: %w", err)
|
||||
}
|
||||
|
||||
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
|
||||
return data, contentType, nil
|
||||
}
|
||||
|
||||
func resolveSnapshotFileName(rawURL string, contentType string) string {
|
||||
if strings.Contains(strings.ToLower(contentType), "text/plain") {
|
||||
return rawURL + ".txt"
|
||||
}
|
||||
return rawURL + ".html"
|
||||
}
|
||||
|
||||
func extractKnowledgeTextFromSnapshot(fileName string, content []byte) (string, error) {
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
if ext != ".html" && ext != ".htm" {
|
||||
return extractKnowledgeTextFromFile(fileName, content)
|
||||
}
|
||||
|
||||
doc, err := html.Parse(bytes.NewReader(content))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse html: %w", err)
|
||||
}
|
||||
|
||||
var parts []string
|
||||
var walk func(*html.Node, bool)
|
||||
walk = func(node *html.Node, skip bool) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
|
||||
nextSkip := skip
|
||||
if node.Type == html.ElementNode {
|
||||
switch strings.ToLower(node.Data) {
|
||||
case "script", "style", "noscript", "svg":
|
||||
nextSkip = true
|
||||
}
|
||||
}
|
||||
|
||||
if node.Type == html.TextNode && !nextSkip {
|
||||
text := strings.TrimSpace(node.Data)
|
||||
if text != "" {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
}
|
||||
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child, nextSkip)
|
||||
}
|
||||
}
|
||||
walk(doc, false)
|
||||
|
||||
return normalizeKnowledgeText(strings.Join(parts, "\n")), nil
|
||||
}
|
||||
|
||||
func extractKnowledgeTextFromFile(fileName string, content []byte) (string, error) {
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
|
||||
switch ext {
|
||||
case ".txt", ".md", ".markdown", ".json", ".csv", ".html", ".htm":
|
||||
if !utf8.Valid(content) {
|
||||
return "", fmt.Errorf("file %s is not valid UTF-8 text", fileName)
|
||||
}
|
||||
return normalizeKnowledgeText(string(content)), nil
|
||||
case ".docx":
|
||||
return extractDOCXText(content)
|
||||
case ".xlsx":
|
||||
return extractXLSXText(content)
|
||||
case ".pdf":
|
||||
return extractPDFText(content)
|
||||
case ".doc", ".xls":
|
||||
return "", fmt.Errorf("file type %s is not supported yet", ext)
|
||||
default:
|
||||
if utf8.Valid(content) {
|
||||
return normalizeKnowledgeText(string(content)), nil
|
||||
}
|
||||
return "", fmt.Errorf("unsupported file type %s", ext)
|
||||
}
|
||||
}
|
||||
|
||||
func extractDOCXText(content []byte) (string, error) {
|
||||
reader, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open docx: %w", err)
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for _, file := range reader.File {
|
||||
if file.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open docx entry: %w", err)
|
||||
}
|
||||
data, err := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read docx entry: %w", err)
|
||||
}
|
||||
|
||||
decoder := xml.NewDecoder(bytes.NewReader(data))
|
||||
for {
|
||||
token, tokenErr := decoder.Token()
|
||||
if tokenErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if tokenErr != nil {
|
||||
return "", fmt.Errorf("decode docx xml: %w", tokenErr)
|
||||
}
|
||||
switch typed := token.(type) {
|
||||
case xml.CharData:
|
||||
text := strings.TrimSpace(string(typed))
|
||||
if text != "" {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "", fmt.Errorf("docx contains no readable text")
|
||||
}
|
||||
return normalizeKnowledgeText(strings.Join(parts, "\n")), nil
|
||||
}
|
||||
|
||||
func extractXLSXText(content []byte) (string, error) {
|
||||
book, err := excelize.OpenReader(bytes.NewReader(content))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open xlsx: %w", err)
|
||||
}
|
||||
defer func() { _ = book.Close() }()
|
||||
|
||||
lines := make([]string, 0)
|
||||
for _, sheet := range book.GetSheetList() {
|
||||
rows, err := book.GetRows(sheet)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read xlsx rows: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, sheet)
|
||||
for _, row := range rows {
|
||||
cells := make([]string, 0, len(row))
|
||||
for _, cell := range row {
|
||||
cell = strings.TrimSpace(cell)
|
||||
if cell != "" {
|
||||
cells = append(cells, cell)
|
||||
}
|
||||
}
|
||||
if len(cells) > 0 {
|
||||
lines = append(lines, strings.Join(cells, " | "))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return "", fmt.Errorf("xlsx contains no readable text")
|
||||
}
|
||||
return normalizeKnowledgeText(strings.Join(lines, "\n")), nil
|
||||
}
|
||||
|
||||
func extractPDFText(content []byte) (string, error) {
|
||||
reader, err := pdf.NewReader(bytes.NewReader(content), int64(len(content)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open pdf: %w", err)
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
totalPage := reader.NumPage()
|
||||
for pageIndex := 1; pageIndex <= totalPage; pageIndex += 1 {
|
||||
page := reader.Page(pageIndex)
|
||||
if page.V.IsNull() {
|
||||
continue
|
||||
}
|
||||
text, err := page.GetPlainText(nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("extract pdf text: %w", err)
|
||||
}
|
||||
if text != "" {
|
||||
builder.WriteString(text)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
result := normalizeKnowledgeText(builder.String())
|
||||
if result == "" {
|
||||
return "", fmt.Errorf("pdf contains no readable text")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeKnowledgeText(input string) string {
|
||||
input = strings.ReplaceAll(input, "\u00a0", " ")
|
||||
lines := strings.Split(input, "\n")
|
||||
cleaned := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = whitespacePattern.ReplaceAllString(strings.TrimSpace(line), " ")
|
||||
if line != "" {
|
||||
cleaned = append(cleaned, line)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(cleaned, "\n"))
|
||||
}
|
||||
|
||||
func splitKnowledgeTextIntoChunks(text string, chunkSize, chunkOverlap int) []string {
|
||||
text = normalizeKnowledgeText(text)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
runes := []rune(text)
|
||||
if chunkSize <= 0 {
|
||||
chunkSize = 900
|
||||
}
|
||||
if chunkOverlap < 0 {
|
||||
chunkOverlap = 0
|
||||
}
|
||||
if chunkOverlap >= chunkSize {
|
||||
chunkOverlap = chunkSize / 4
|
||||
}
|
||||
|
||||
chunks := make([]string, 0)
|
||||
for start := 0; start < len(runes); {
|
||||
end := start + chunkSize
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
|
||||
if end < len(runes) {
|
||||
for offset := end; offset > start+chunkSize/2; offset -= 1 {
|
||||
if runes[offset-1] == '\n' || runes[offset-1] == '。' || runes[offset-1] == '.' {
|
||||
end = offset
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chunk := strings.TrimSpace(string(runes[start:end]))
|
||||
if chunk != "" {
|
||||
chunks = append(chunks, chunk)
|
||||
}
|
||||
if end >= len(runes) {
|
||||
break
|
||||
}
|
||||
start = end - chunkOverlap
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
func estimateKnowledgeTokenCount(text string) int {
|
||||
if text == "" {
|
||||
return 0
|
||||
}
|
||||
return utf8.RuneCountInString(text)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,12 +15,14 @@ import (
|
||||
"github.com/geo-platform/tenant-api/internal/shared/llm"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/stream"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/prompts"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||
)
|
||||
|
||||
type PromptRuleGenerationService struct {
|
||||
pool *pgxpool.Pool
|
||||
llm llm.Client
|
||||
knowledge *KnowledgeService
|
||||
streams *stream.GenerationHub
|
||||
streamEnabled bool
|
||||
articleTimeout time.Duration
|
||||
@@ -53,13 +55,15 @@ type GenerateFromRuleResponse struct {
|
||||
}
|
||||
|
||||
type promptRuleGenerationRecord struct {
|
||||
ID int64
|
||||
Name string
|
||||
PromptContent string
|
||||
Scene *string
|
||||
DefaultTone *string
|
||||
DefaultWordCount *int
|
||||
Status string
|
||||
ID int64
|
||||
Name string
|
||||
PromptContent string
|
||||
Scene *string
|
||||
DefaultTone *string
|
||||
DefaultWordCount *int
|
||||
KnowledgeGroupIDs []int64
|
||||
KnowledgeGroups []PromptRuleKnowledgeGroup
|
||||
Status string
|
||||
}
|
||||
|
||||
const promptRuleGeneratingTitlePlaceholder = "标题生成中"
|
||||
@@ -67,6 +71,7 @@ const promptRuleGeneratingTitlePlaceholder = "标题生成中"
|
||||
func NewPromptRuleGenerationService(
|
||||
pool *pgxpool.Pool,
|
||||
llmClient llm.Client,
|
||||
knowledge *KnowledgeService,
|
||||
streams *stream.GenerationHub,
|
||||
cfg config.GenerationConfig,
|
||||
llmMaxOutputTokens int64,
|
||||
@@ -89,6 +94,7 @@ func NewPromptRuleGenerationService(
|
||||
svc := &PromptRuleGenerationService{
|
||||
pool: pool,
|
||||
llm: llmClient,
|
||||
knowledge: knowledge,
|
||||
streams: streams,
|
||||
streamEnabled: cfg.StreamEnabled,
|
||||
articleTimeout: articleTimeout,
|
||||
@@ -153,6 +159,26 @@ func (s *PromptRuleGenerationService) GenerateFromRule(ctx context.Context, req
|
||||
if req.BrandID != nil {
|
||||
inputParams["brand_id"] = *req.BrandID
|
||||
}
|
||||
if len(rule.KnowledgeGroupIDs) > 0 {
|
||||
inputParams["knowledge_group_ids"] = rule.KnowledgeGroupIDs
|
||||
}
|
||||
|
||||
knowledgePrompt := ""
|
||||
if len(rule.KnowledgeGroupIDs) > 0 {
|
||||
if s.knowledge == nil {
|
||||
return nil, response.ErrServiceUnavailable(50352, "knowledge_unavailable", "knowledge service is not configured")
|
||||
}
|
||||
knowledgeContext, resolveErr := s.knowledge.ResolveContext(
|
||||
ctx,
|
||||
actor.TenantID,
|
||||
rule.KnowledgeGroupIDs,
|
||||
buildPromptRuleKnowledgeQuery(rule, inputParams),
|
||||
)
|
||||
if resolveErr != nil {
|
||||
return nil, resolveErr
|
||||
}
|
||||
knowledgePrompt = s.knowledge.RenderPromptSection(knowledgeContext)
|
||||
}
|
||||
|
||||
inputJSON, _ := json.Marshal(inputParams)
|
||||
wizardStateJSON, _ := mergeArticleWizardState(nil, promptRuleGeneratingTitlePlaceholder, platformIDs)
|
||||
@@ -233,7 +259,7 @@ func (s *PromptRuleGenerationService) GenerateFromRule(ctx context.Context, req
|
||||
ArticleID: articleID,
|
||||
TaskID: taskID,
|
||||
ReservationID: reservationID,
|
||||
Prompt: buildPromptRuleGenerationPrompt(rule, inputParams),
|
||||
Prompt: buildPromptRuleGenerationPrompt(rule, inputParams, knowledgePrompt),
|
||||
InputParams: inputParams,
|
||||
InitialTitle: promptRuleGeneratingTitlePlaceholder,
|
||||
WebSearch: llm.WebSearchOptions{
|
||||
@@ -271,6 +297,28 @@ func (s *PromptRuleGenerationService) loadPromptRule(ctx context.Context, tenant
|
||||
if err != nil {
|
||||
return nil, response.ErrNotFound(40431, "rule_not_found", "prompt rule not found")
|
||||
}
|
||||
knowledgeRows, err := s.pool.Query(ctx, `
|
||||
SELECT kg.id, kg.name
|
||||
FROM prompt_rule_knowledge_groups prkg
|
||||
JOIN knowledge_groups kg ON kg.id = prkg.knowledge_group_id
|
||||
WHERE prkg.prompt_rule_id = $1
|
||||
AND kg.tenant_id = $2
|
||||
AND kg.deleted_at IS NULL
|
||||
ORDER BY kg.sort_order, kg.created_at, kg.id
|
||||
`, promptRuleID, tenantID)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50010, "knowledge_group_query_failed", "failed to query knowledge groups")
|
||||
}
|
||||
defer knowledgeRows.Close()
|
||||
|
||||
for knowledgeRows.Next() {
|
||||
var group PromptRuleKnowledgeGroup
|
||||
if err := knowledgeRows.Scan(&group.ID, &group.Name); err != nil {
|
||||
return nil, response.ErrInternal(50010, "knowledge_group_query_failed", err.Error())
|
||||
}
|
||||
record.KnowledgeGroups = append(record.KnowledgeGroups, group)
|
||||
record.KnowledgeGroupIDs = append(record.KnowledgeGroupIDs, group.ID)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
@@ -447,34 +495,60 @@ func (s *PromptRuleGenerationService) failGeneration(ctx context.Context, job pr
|
||||
}
|
||||
}
|
||||
|
||||
func buildPromptRuleGenerationPrompt(rule *promptRuleGenerationRecord, params map[string]interface{}) string {
|
||||
func buildPromptRuleGenerationPrompt(rule *promptRuleGenerationRecord, params map[string]interface{}, knowledgePrompt string) string {
|
||||
sections := []string{strings.TrimSpace(rule.PromptContent)}
|
||||
|
||||
var supplements []string
|
||||
if taskName := strings.TrimSpace(extractString(params, "task_name")); taskName != "" {
|
||||
supplements = append(supplements, "任务名称:"+taskName)
|
||||
supplements = append(supplements, prompts.PromptRuleTaskNameSupplement(taskName))
|
||||
}
|
||||
if rule.Scene != nil && strings.TrimSpace(*rule.Scene) != "" {
|
||||
supplements = append(supplements, "适用场景:"+strings.TrimSpace(*rule.Scene))
|
||||
supplements = append(supplements, prompts.PromptRuleSceneSupplement(strings.TrimSpace(*rule.Scene)))
|
||||
}
|
||||
if rule.DefaultTone != nil && strings.TrimSpace(*rule.DefaultTone) != "" {
|
||||
supplements = append(supplements, "默认语气:"+strings.TrimSpace(*rule.DefaultTone))
|
||||
supplements = append(supplements, prompts.PromptRuleToneSupplement(strings.TrimSpace(*rule.DefaultTone)))
|
||||
}
|
||||
if rule.DefaultWordCount != nil && *rule.DefaultWordCount > 0 {
|
||||
supplements = append(supplements, "建议字数:"+strconv.Itoa(*rule.DefaultWordCount)+" 字左右")
|
||||
supplements = append(supplements, prompts.PromptRuleWordCountSupplement(*rule.DefaultWordCount))
|
||||
}
|
||||
if target := strings.TrimSpace(extractString(params, "target_platform")); target != "" {
|
||||
supplements = append(supplements, "目标发布平台:"+strings.ReplaceAll(target, ",", "、"))
|
||||
supplements = append(supplements, prompts.PromptRuleTargetPlatformSupplement(target))
|
||||
}
|
||||
|
||||
if len(supplements) > 0 {
|
||||
sections = append(sections, "补充要求:\n- "+strings.Join(supplements, "\n- "))
|
||||
sections = append(sections, prompts.PromptRuleSupplementSection(supplements))
|
||||
}
|
||||
|
||||
sections = append(sections, "输出要求:\n1. 直接输出完整可发布的中文 Markdown 文章。\n2. 标题使用一级标题,并由 AI 根据正文内容自行生成,不要直接复用任务名称。\n3. 不要输出你的思考过程、解释或致歉。")
|
||||
if strings.TrimSpace(knowledgePrompt) != "" {
|
||||
sections = append(sections, knowledgePrompt)
|
||||
}
|
||||
|
||||
sections = append(sections, prompts.PromptRuleOutputRequirementsSection())
|
||||
return strings.Join(sections, "\n\n")
|
||||
}
|
||||
|
||||
func buildPromptRuleKnowledgeQuery(rule *promptRuleGenerationRecord, params map[string]interface{}) string {
|
||||
parts := make([]string, 0, 6)
|
||||
if rule != nil {
|
||||
if text := strings.TrimSpace(rule.Name); text != "" {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
if rule.Scene != nil && strings.TrimSpace(*rule.Scene) != "" {
|
||||
parts = append(parts, strings.TrimSpace(*rule.Scene))
|
||||
}
|
||||
if text := strings.TrimSpace(rule.PromptContent); text != "" {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
}
|
||||
if taskName := strings.TrimSpace(extractString(params, "task_name")); taskName != "" {
|
||||
parts = append(parts, taskName)
|
||||
}
|
||||
if target := strings.TrimSpace(extractString(params, "target_platform")); target != "" {
|
||||
parts = append(parts, target)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func extractGenerateCount(extra map[string]interface{}) int {
|
||||
value := extractInt(extra, "generate_count")
|
||||
if value <= 0 {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
|
||||
@@ -39,35 +40,43 @@ type PromptRuleGroupResponse struct {
|
||||
// --- Rule types ---
|
||||
|
||||
type PromptRuleRequest struct {
|
||||
GroupID *int64 `json:"group_id"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
PromptContent string `json:"prompt_content" binding:"required"`
|
||||
Scene *string `json:"scene"`
|
||||
DefaultTone *string `json:"default_tone"`
|
||||
DefaultWordCount *int `json:"default_word_count"`
|
||||
GroupID *int64 `json:"group_id"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
PromptContent string `json:"prompt_content" binding:"required"`
|
||||
Scene *string `json:"scene"`
|
||||
DefaultTone *string `json:"default_tone"`
|
||||
DefaultWordCount *int `json:"default_word_count"`
|
||||
KnowledgeGroupIDs []int64 `json:"knowledge_group_ids"`
|
||||
}
|
||||
|
||||
type PromptRuleKnowledgeGroup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type PromptRuleResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
GroupID *int64 `json:"group_id"`
|
||||
Name string `json:"name"`
|
||||
PromptContent string `json:"prompt_content"`
|
||||
Scene *string `json:"scene"`
|
||||
DefaultTone *string `json:"default_tone"`
|
||||
DefaultWordCount *int `json:"default_word_count"`
|
||||
Status string `json:"status"`
|
||||
ArticleCount int `json:"article_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID int64 `json:"id"`
|
||||
GroupID *int64 `json:"group_id"`
|
||||
Name string `json:"name"`
|
||||
PromptContent string `json:"prompt_content"`
|
||||
Scene *string `json:"scene"`
|
||||
DefaultTone *string `json:"default_tone"`
|
||||
DefaultWordCount *int `json:"default_word_count"`
|
||||
KnowledgeGroupIDs []int64 `json:"knowledge_group_ids"`
|
||||
KnowledgeGroups []PromptRuleKnowledgeGroup `json:"knowledge_groups"`
|
||||
Status string `json:"status"`
|
||||
ArticleCount int `json:"article_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PromptRuleListParams struct {
|
||||
GroupID *int64 `json:"group_id"`
|
||||
Status *string `json:"status"`
|
||||
Keyword *string `json:"keyword"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Ungrouped bool `json:"ungrouped"`
|
||||
GroupID *int64 `json:"group_id"`
|
||||
Status *string `json:"status"`
|
||||
Keyword *string `json:"keyword"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Ungrouped bool `json:"ungrouped"`
|
||||
}
|
||||
|
||||
type PromptRuleListResponse struct {
|
||||
@@ -297,12 +306,23 @@ func (s *PromptRuleService) List(ctx context.Context, params PromptRuleListParam
|
||||
}
|
||||
r.CreatedAt = fmt.Sprintf("%v", ca)
|
||||
r.UpdatedAt = fmt.Sprintf("%v", ua)
|
||||
r.KnowledgeGroupIDs = []int64{}
|
||||
r.KnowledgeGroups = []PromptRuleKnowledgeGroup{}
|
||||
items = append(items, r)
|
||||
}
|
||||
if items == nil {
|
||||
items = []PromptRuleResponse{}
|
||||
}
|
||||
|
||||
groupMap, err := s.loadPromptRuleKnowledgeGroupMap(ctx, actor.TenantID, collectPromptRuleIDs(items))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index := range items {
|
||||
items[index].KnowledgeGroups = groupMap[items[index].ID]
|
||||
items[index].KnowledgeGroupIDs = promptRuleKnowledgeGroupIDs(items[index].KnowledgeGroups)
|
||||
}
|
||||
|
||||
return &PromptRuleListResponse{Items: items, Total: total}, nil
|
||||
}
|
||||
|
||||
@@ -327,14 +347,32 @@ func (s *PromptRuleService) Detail(ctx context.Context, id int64) (*PromptRuleRe
|
||||
r.CreatedAt = fmt.Sprintf("%v", ca)
|
||||
r.UpdatedAt = fmt.Sprintf("%v", ua)
|
||||
r.ArticleCount = articleCount
|
||||
|
||||
groupMap, groupErr := s.loadPromptRuleKnowledgeGroupMap(ctx, actor.TenantID, []int64{id})
|
||||
if groupErr != nil {
|
||||
return nil, groupErr
|
||||
}
|
||||
r.KnowledgeGroups = groupMap[id]
|
||||
r.KnowledgeGroupIDs = promptRuleKnowledgeGroupIDs(r.KnowledgeGroups)
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (s *PromptRuleService) Create(ctx context.Context, req PromptRuleRequest) (*PromptRuleResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
knowledgeGroups, err := s.validatePromptRuleKnowledgeGroups(ctx, actor.TenantID, req.KnowledgeGroupIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50010, "create_failed", "failed to create prompt rule")
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var id int64
|
||||
var ca interface{}
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO prompt_rules (tenant_id, group_id, name, prompt_content, scene, default_tone, default_word_count)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at
|
||||
`, actor.TenantID, req.GroupID, req.Name, req.PromptContent, req.Scene, req.DefaultTone, req.DefaultWordCount).Scan(&id, &ca)
|
||||
@@ -342,6 +380,13 @@ func (s *PromptRuleService) Create(ctx context.Context, req PromptRuleRequest) (
|
||||
return nil, response.ErrInternal(50010, "create_failed", "failed to create prompt rule")
|
||||
}
|
||||
|
||||
if err := s.replacePromptRuleKnowledgeGroups(ctx, tx, id, promptRuleKnowledgeGroupIDs(knowledgeGroups)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, response.ErrInternal(50010, "create_failed", "failed to commit prompt rule")
|
||||
}
|
||||
|
||||
afterJSON, _ := json.Marshal(map[string]interface{}{"id": id, "name": req.Name})
|
||||
result := "success"
|
||||
resourceType := "prompt_rule"
|
||||
@@ -359,15 +404,36 @@ func (s *PromptRuleService) Create(ctx context.Context, req PromptRuleRequest) (
|
||||
})
|
||||
|
||||
return &PromptRuleResponse{
|
||||
ID: id, GroupID: req.GroupID, Name: req.Name, PromptContent: req.PromptContent,
|
||||
Scene: req.Scene, DefaultTone: req.DefaultTone, DefaultWordCount: req.DefaultWordCount,
|
||||
Status: "enabled", ArticleCount: 0, CreatedAt: fmt.Sprintf("%v", ca),
|
||||
ID: id,
|
||||
GroupID: req.GroupID,
|
||||
Name: req.Name,
|
||||
PromptContent: req.PromptContent,
|
||||
Scene: req.Scene,
|
||||
DefaultTone: req.DefaultTone,
|
||||
DefaultWordCount: req.DefaultWordCount,
|
||||
KnowledgeGroupIDs: promptRuleKnowledgeGroupIDs(knowledgeGroups),
|
||||
KnowledgeGroups: knowledgeGroups,
|
||||
Status: "enabled",
|
||||
ArticleCount: 0,
|
||||
CreatedAt: fmt.Sprintf("%v", ca),
|
||||
UpdatedAt: fmt.Sprintf("%v", ca),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *PromptRuleService) Update(ctx context.Context, id int64, req PromptRuleRequest) error {
|
||||
actor := auth.MustActor(ctx)
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
knowledgeGroups, err := s.validatePromptRuleKnowledgeGroups(ctx, actor.TenantID, req.KnowledgeGroupIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return response.ErrInternal(50010, "update_failed", "failed to update prompt rule")
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE prompt_rules SET name = $1, group_id = $2, prompt_content = $3,
|
||||
scene = $4, default_tone = $5, default_word_count = $6, updated_at = NOW()
|
||||
WHERE id = $7 AND tenant_id = $8 AND deleted_at IS NULL
|
||||
@@ -375,6 +441,12 @@ func (s *PromptRuleService) Update(ctx context.Context, id int64, req PromptRule
|
||||
if err != nil || tag.RowsAffected() == 0 {
|
||||
return response.ErrNotFound(40431, "rule_not_found", "prompt rule not found")
|
||||
}
|
||||
if err := s.replacePromptRuleKnowledgeGroups(ctx, tx, id, promptRuleKnowledgeGroupIDs(knowledgeGroups)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return response.ErrInternal(50010, "update_failed", "failed to commit prompt rule")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -443,3 +515,122 @@ func (s *PromptRuleService) ListSimple(ctx context.Context) ([]PromptRuleSimple,
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *PromptRuleService) validatePromptRuleKnowledgeGroups(
|
||||
ctx context.Context,
|
||||
tenantID int64,
|
||||
groupIDs []int64,
|
||||
) ([]PromptRuleKnowledgeGroup, error) {
|
||||
groupIDs = normalizeInt64IDs(groupIDs)
|
||||
if len(groupIDs) == 0 {
|
||||
return []PromptRuleKnowledgeGroup{}, nil
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, name
|
||||
FROM knowledge_groups
|
||||
WHERE tenant_id = $1 AND id = ANY($2::bigint[]) AND deleted_at IS NULL
|
||||
ORDER BY sort_order, created_at, id
|
||||
`, tenantID, groupIDs)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50010, "knowledge_group_query_failed", "failed to query knowledge groups")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
groups := make([]PromptRuleKnowledgeGroup, 0, len(groupIDs))
|
||||
found := make(map[int64]struct{}, len(groupIDs))
|
||||
for rows.Next() {
|
||||
var item PromptRuleKnowledgeGroup
|
||||
if err := rows.Scan(&item.ID, &item.Name); err != nil {
|
||||
return nil, response.ErrInternal(50010, "knowledge_group_query_failed", err.Error())
|
||||
}
|
||||
groups = append(groups, item)
|
||||
found[item.ID] = struct{}{}
|
||||
}
|
||||
|
||||
if len(found) != len(groupIDs) {
|
||||
return nil, response.ErrNotFound(40451, "knowledge_group_not_found", "knowledge group not found")
|
||||
}
|
||||
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func (s *PromptRuleService) loadPromptRuleKnowledgeGroupMap(
|
||||
ctx context.Context,
|
||||
tenantID int64,
|
||||
ruleIDs []int64,
|
||||
) (map[int64][]PromptRuleKnowledgeGroup, error) {
|
||||
result := make(map[int64][]PromptRuleKnowledgeGroup, len(ruleIDs))
|
||||
ruleIDs = normalizeInt64IDs(ruleIDs)
|
||||
if len(ruleIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT prkg.prompt_rule_id, kg.id, kg.name
|
||||
FROM prompt_rule_knowledge_groups prkg
|
||||
JOIN knowledge_groups kg ON kg.id = prkg.knowledge_group_id
|
||||
WHERE prkg.prompt_rule_id = ANY($1::bigint[])
|
||||
AND kg.tenant_id = $2
|
||||
AND kg.deleted_at IS NULL
|
||||
ORDER BY kg.sort_order, kg.created_at, kg.id
|
||||
`, ruleIDs, tenantID)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50010, "knowledge_group_query_failed", "failed to query prompt rule knowledge groups")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
ruleID int64
|
||||
group PromptRuleKnowledgeGroup
|
||||
)
|
||||
if err := rows.Scan(&ruleID, &group.ID, &group.Name); err != nil {
|
||||
return nil, response.ErrInternal(50010, "knowledge_group_query_failed", err.Error())
|
||||
}
|
||||
result[ruleID] = append(result[ruleID], group)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *PromptRuleService) replacePromptRuleKnowledgeGroups(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
promptRuleID int64,
|
||||
groupIDs []int64,
|
||||
) error {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM prompt_rule_knowledge_groups
|
||||
WHERE prompt_rule_id = $1
|
||||
`, promptRuleID); err != nil {
|
||||
return response.ErrInternal(50010, "update_failed", "failed to update prompt rule knowledge groups")
|
||||
}
|
||||
|
||||
for _, groupID := range normalizeInt64IDs(groupIDs) {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO prompt_rule_knowledge_groups (prompt_rule_id, knowledge_group_id)
|
||||
VALUES ($1, $2)
|
||||
`, promptRuleID, groupID); err != nil {
|
||||
return response.ErrInternal(50010, "update_failed", "failed to update prompt rule knowledge groups")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectPromptRuleIDs(items []PromptRuleResponse) []int64 {
|
||||
ids := make([]int64, 0, len(items))
|
||||
for _, item := range items {
|
||||
ids = append(ids, item.ID)
|
||||
}
|
||||
return normalizeInt64IDs(ids)
|
||||
}
|
||||
|
||||
func promptRuleKnowledgeGroupIDs(groups []PromptRuleKnowledgeGroup) []int64 {
|
||||
ids := make([]int64, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
ids = append(ids, group.ID)
|
||||
}
|
||||
return normalizeInt64IDs(ids)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/llm"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/prompts"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||
)
|
||||
|
||||
@@ -474,7 +475,7 @@ func (s *TemplateService) executeOutlineTask(ctx context.Context, repo repositor
|
||||
ResponseFormat: &llm.ResponseFormat{
|
||||
Type: llm.ResponseFormatTypeJSONObject,
|
||||
Name: "article_outline",
|
||||
Description: "文章大纲 JSON 对象,必须包含 outline 数组字段。",
|
||||
Description: prompts.OutlineResponseFormatDescription(),
|
||||
},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
@@ -666,56 +667,12 @@ func buildAnalyzePrompt(templateKey, templateName string, req AnalyzeTaskRequest
|
||||
basePrompt := strings.TrimSpace(renderPromptTemplate(analyzePromptTemplate, contextPayload))
|
||||
if basePrompt != "" {
|
||||
contextJSON, _ := json.MarshalIndent(contextPayload, "", " ")
|
||||
outputExample := `{
|
||||
"brand_summary": "一句简洁的品牌/主题描述",
|
||||
"keywords": ["关键词1", "关键词2", "关键词3", "关键词4", "关键词5"],
|
||||
"competitors": [
|
||||
{
|
||||
"name": "竞品名",
|
||||
"website": "https://example.com",
|
||||
"description": "一句简介"
|
||||
}
|
||||
]
|
||||
}`
|
||||
return strings.TrimSpace(fmt.Sprintf("%s\n\n模板上下文:\n%s\n\nJSON 输出示例:\n%s", basePrompt, string(contextJSON), outputExample))
|
||||
return prompts.TemplateContextWithJSONExample(basePrompt, string(contextJSON), prompts.AnalyzeOutputExample())
|
||||
}
|
||||
}
|
||||
|
||||
contextJSON, _ := json.MarshalIndent(contextPayload, "", " ")
|
||||
outputExample := `{
|
||||
"brand_summary": "一句简洁的品牌/主题描述",
|
||||
"keywords": ["关键词1", "关键词2", "关键词3", "关键词4", "关键词5"],
|
||||
"competitors": [
|
||||
{
|
||||
"name": "竞品名",
|
||||
"website": "https://example.com",
|
||||
"description": "一句简介"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
return strings.TrimSpace(fmt.Sprintf(`
|
||||
你是一位专业的 GEO 内容策略师。
|
||||
|
||||
任务:
|
||||
1. 分析品牌/主题上下文。
|
||||
2. 推荐最相关的 GEO 文章关键词。
|
||||
3. 推荐可信的竞品网站或竞争品牌,用于对比或引用。
|
||||
|
||||
规则:
|
||||
- 仅返回 JSON,不要用 Markdown 代码块包裹。
|
||||
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
|
||||
- 关键词应为简洁的搜索短语。
|
||||
- 竞品应去重且真实。不确定时 website 字段可留空,但不要编造虚假链接。
|
||||
- brand_summary 限 1-2 句。
|
||||
- 最多返回 6 个竞品和 5 个关键词。
|
||||
|
||||
模板上下文:
|
||||
%s
|
||||
|
||||
JSON 输出示例:
|
||||
%s
|
||||
`, string(contextJSON), outputExample))
|
||||
return prompts.AnalyzeFallbackPrompt(string(contextJSON))
|
||||
}
|
||||
|
||||
func buildTitlePrompt(templateKey, templateName string, req TitleTaskRequest, titlePromptTemplate *string) string {
|
||||
@@ -725,115 +682,36 @@ func buildTitlePrompt(templateKey, templateName string, req TitleTaskRequest, ti
|
||||
if basePrompt != "" {
|
||||
sections := []string{basePrompt}
|
||||
if contextBlock := buildPromptContext(contextPayload); contextBlock != "" {
|
||||
sections = append(sections, "当前上下文:\n"+contextBlock)
|
||||
sections = append(sections, prompts.PromptContextSection(contextBlock))
|
||||
}
|
||||
sections = append(sections, strings.Join([]string{
|
||||
"输出要求:",
|
||||
"- 仅返回 JSON,不要用 Markdown 代码块包裹。",
|
||||
"- 返回恰好 5 个字符串的 JSON 数组。",
|
||||
"- 尊重当前关键词列表和竞品上下文,因为用户可能已手动编辑。",
|
||||
}, "\n"))
|
||||
sections = append(sections, prompts.TitleCustomOutputRequirementsSection())
|
||||
return strings.Join(sections, "\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
contextJSON, _ := json.MarshalIndent(contextPayload, "", " ")
|
||||
outputExample := `[
|
||||
"标题 1",
|
||||
"标题 2",
|
||||
"标题 3",
|
||||
"标题 4",
|
||||
"标题 5"
|
||||
]`
|
||||
|
||||
return strings.TrimSpace(fmt.Sprintf(`
|
||||
你是一位专业的 GEO 内容策略师。
|
||||
|
||||
任务:
|
||||
为当前内容摘要生成 5 个优质文章标题候选。
|
||||
|
||||
规则:
|
||||
- 仅返回 JSON,不要用 Markdown 代码块包裹。
|
||||
- 返回 5 个字符串的 JSON 数组,不要返回对象。
|
||||
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
|
||||
- 标题应实用、具体,并契合当前模板的内容方向。
|
||||
- 尊重当前关键词列表和竞品上下文,因为用户可能已手动编辑。
|
||||
- 避免泛泛的广告文案和空洞口号。
|
||||
|
||||
模板上下文:
|
||||
%s
|
||||
|
||||
JSON 输出示例:
|
||||
%s
|
||||
`, string(contextJSON), outputExample))
|
||||
return prompts.TitleFallbackPrompt(string(contextJSON))
|
||||
}
|
||||
|
||||
func buildOutlinePrompt(templateKey, templateName string, req OutlineTaskRequest, outlinePromptTemplate *string) string {
|
||||
contextPayload := outlinePromptParams(templateKey, templateName, req)
|
||||
outputExample := `{
|
||||
"outline": [
|
||||
{
|
||||
"outline": "网站列表",
|
||||
"children": [
|
||||
{ "outline": "竞品 A" },
|
||||
{ "outline": "竞品 B" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"outline": "文章关键要点",
|
||||
"children": [
|
||||
{ "outline": "要点 1" },
|
||||
{ "outline": "要点 2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
outputExample := prompts.OutlineOutputExample()
|
||||
|
||||
if outlinePromptTemplate != nil && strings.TrimSpace(*outlinePromptTemplate) != "" {
|
||||
basePrompt := strings.TrimSpace(renderPromptTemplate(outlinePromptTemplate, contextPayload))
|
||||
if basePrompt != "" {
|
||||
sections := []string{basePrompt}
|
||||
if contextBlock := buildPromptContext(contextPayload); contextBlock != "" {
|
||||
sections = append(sections, "当前上下文:\n"+contextBlock)
|
||||
sections = append(sections, prompts.PromptContextSection(contextBlock))
|
||||
}
|
||||
sections = append(sections, strings.Join([]string{
|
||||
"输出要求:",
|
||||
"- 仅返回 JSON,不要用 Markdown 代码块包裹。",
|
||||
`- 返回 JSON 对象,格式固定为 {"outline":[...]}`,
|
||||
`- outline 字段是顶层大纲节点数组,每个节点格式为 {"outline":"...","children":[...]}`,
|
||||
"- 每个已选大纲段落应作为顶层节点出现,语言与请求保持一致。",
|
||||
"- 尊重当前关键词、竞品和关键要点,因为用户可能已手动编辑。",
|
||||
"- 不要返回额外字段,不要返回纯数组,不要返回说明文字。",
|
||||
}, "\n"))
|
||||
sections = append(sections, "JSON 输出示例:\n"+outputExample)
|
||||
sections = append(sections, prompts.OutlineCustomOutputRequirementsSection())
|
||||
sections = append(sections, prompts.JSONOutputExampleSection(outputExample))
|
||||
return strings.Join(sections, "\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
contextJSON, _ := json.MarshalIndent(contextPayload, "", " ")
|
||||
|
||||
return strings.TrimSpace(fmt.Sprintf(`
|
||||
你是一位专业的 GEO 内容策略师。
|
||||
|
||||
任务:
|
||||
为当前内容摘要生成一份实用的文章大纲。
|
||||
|
||||
规则:
|
||||
- 仅返回 JSON,不要用 Markdown 代码块包裹。
|
||||
- 返回 JSON 对象,格式固定为 {"outline":[...]}。
|
||||
- outline 字段中的每个顶层节点 "outline" 值必须保持已选段落标签原文。
|
||||
- 在合适的位置为每个顶层段落添加简洁的子大纲条目。
|
||||
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
|
||||
- 尊重当前标题、关键词、竞品、关键要点和品牌上下文,因为用户可能已手动编辑。
|
||||
- 大纲应具体、不含推广语气,适合后续完整成文。
|
||||
- 不要返回额外字段,不要返回纯数组,不要返回说明文字。
|
||||
|
||||
模板上下文:
|
||||
%s
|
||||
|
||||
JSON 输出示例:
|
||||
%s
|
||||
`, string(contextJSON), outputExample))
|
||||
return prompts.OutlineFallbackPrompt(string(contextJSON))
|
||||
}
|
||||
|
||||
func titlePromptParams(templateKey, templateName string, req TitleTaskRequest) map[string]interface{} {
|
||||
|
||||
@@ -7,14 +7,20 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/contentstats"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/prompts"
|
||||
)
|
||||
|
||||
var promptVariablePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_]+)\s*\}\}`)
|
||||
|
||||
func buildGenerationPrompt(templateKey, templateName string, promptTemplate *string, params map[string]interface{}) string {
|
||||
func buildGenerationPrompt(
|
||||
templateKey, templateName string,
|
||||
promptTemplate *string,
|
||||
params map[string]interface{},
|
||||
knowledgePrompt string,
|
||||
) string {
|
||||
basePrompt := strings.TrimSpace(renderPromptTemplate(promptTemplate, params))
|
||||
if basePrompt == "" {
|
||||
basePrompt = fmt.Sprintf("你是一名专业内容编辑,请基于已提供的信息,为模板「%s」写出一篇完整的 Markdown 文章。", templateName)
|
||||
basePrompt = prompts.DefaultGenerationBasePrompt(templateName)
|
||||
}
|
||||
|
||||
var sections []string
|
||||
@@ -22,35 +28,52 @@ func buildGenerationPrompt(templateKey, templateName string, promptTemplate *str
|
||||
|
||||
contextBlock := buildPromptContext(params)
|
||||
if contextBlock != "" {
|
||||
sections = append(sections, "当前上下文:\n"+contextBlock)
|
||||
sections = append(sections, prompts.PromptContextSection(contextBlock))
|
||||
}
|
||||
|
||||
sections = append(sections, strings.Join([]string{
|
||||
"写作总要求:",
|
||||
"- 仅返回文章 Markdown 正文,不要附带额外说明、提示语或代码块。",
|
||||
"- 输出语言与 locale 一致:zh-CN 使用简体中文,en-US 使用自然、专业的英语。",
|
||||
"- 如提供了 title,使用该标题作为文章主标题,并围绕它展开,不要另起一个无关标题。",
|
||||
"- 如提供了 article_outline,一级节点是正文的最终小节标题,必须按顺序展开;二级及更深节点仅作为该小节的行文思路、论证顺序或信息要点,不要机械写成额外标题。",
|
||||
"- 除主标题和一级小节标题外,默认不要把大纲子节点直接写成 Markdown 标题;子节点内容应自然融入段落、列表或过渡句中。",
|
||||
"- 如提供了 key_points,正文中必须覆盖这些重点,不要遗漏。",
|
||||
"- 每个核心段落都要有明确判断、原因解释、适用场景或对比维度,避免空话、套话和重复表述。",
|
||||
"- 信息不足时,不要编造具体事实、价格、数据、测试结果、用户评价或机构结论;可以用稳妥表述说明判断边界。",
|
||||
"- 保持客观、克制、非广告化,不使用夸张宣传语,如“顶级”“颠覆性”“完美”等。",
|
||||
"- 使用清晰的小节标题和短段落;仅在确实有助于理解时使用列表。",
|
||||
"- 结尾应给出清晰结论、适合对象、不适合对象,或下一步建议,帮助读者完成判断。",
|
||||
}, "\n"))
|
||||
if strings.TrimSpace(knowledgePrompt) != "" {
|
||||
sections = append(sections, knowledgePrompt)
|
||||
}
|
||||
|
||||
sections = append(sections, prompts.GenerationWritingRequirementsSection())
|
||||
|
||||
if templateRules := buildTemplateSpecificWritingRules(templateKey, params); templateRules != "" {
|
||||
sections = append(sections, "模板专项要求:\n"+templateRules)
|
||||
sections = append(sections, prompts.GenerationTemplateSpecificRulesHeading()+"\n"+templateRules)
|
||||
}
|
||||
|
||||
if lengthGuidance := buildGenerationLengthGuidance(params); lengthGuidance != "" {
|
||||
sections = append(sections, "篇幅要求:\n"+lengthGuidance)
|
||||
sections = append(sections, prompts.GenerationLengthGuidanceHeading()+"\n"+lengthGuidance)
|
||||
}
|
||||
|
||||
return strings.Join(sections, "\n\n")
|
||||
}
|
||||
|
||||
func buildGenerationKnowledgeQuery(params map[string]interface{}) string {
|
||||
if len(params) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := make([]string, 0, 8)
|
||||
appendValue := func(value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
parts = append(parts, value)
|
||||
}
|
||||
|
||||
appendValue(stringValue(params["title"]))
|
||||
appendValue(stringValue(params["topic"]))
|
||||
appendValue(stringValue(params["product_name"]))
|
||||
appendValue(stringValue(params["subject"]))
|
||||
appendValue(stringValue(params["brand_name"]))
|
||||
appendValue(stringValue(params["primary_keyword"]))
|
||||
appendValue(stringValue(params["key_points"]))
|
||||
appendValue(stringValue(params["review_intro_hook"]))
|
||||
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func buildTemplateSpecificWritingRules(templateKey string, params map[string]interface{}) string {
|
||||
switch strings.TrimSpace(templateKey) {
|
||||
case "top_x_article":
|
||||
@@ -58,14 +81,7 @@ func buildTemplateSpecificWritingRules(templateKey string, params map[string]int
|
||||
if brandName == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.Join([]string{
|
||||
fmt.Sprintf("- 若文章包含品牌、公司或服务商榜单,请将「%s」排在第 1 位,并作为全文主推对象。", brandName),
|
||||
fmt.Sprintf("- 「%s」对应条目的篇幅应明显多于其他对象,优先展开其核心优势、本地化服务、适合人群、报价或配置亮点、交付安装、售后表现与避坑建议。", brandName),
|
||||
"- 其他对象保留客观介绍,但篇幅更短,重点写与主推对象的差异点和各自适用场景,不要平均分配篇幅。",
|
||||
fmt.Sprintf("- 结论部分先明确推荐「%s」作为优先选择,再简要补充其他对象分别适合哪些预算、户型或需求。", brandName),
|
||||
fmt.Sprintf("- 如果标题或正文属于“Top X / 推荐 / 最好 / 排行榜”这类排序导向内容,榜单顺序必须与结论保持一致,将「%s」放在第 1 位。", brandName),
|
||||
"- 在不编造事实、不捏造数据的前提下,优先呈现主推对象的优势信息;对其他对象保持克制、客观的弱化式比较。",
|
||||
}, "\n")
|
||||
return prompts.TopXBrandPriorityRules(brandName)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -91,11 +107,7 @@ func buildGenerationLengthGuidance(params map[string]interface{}) string {
|
||||
if maxWords > 2100 {
|
||||
maxWords = 2100
|
||||
}
|
||||
return strings.Join([]string{
|
||||
fmt.Sprintf("- 建议全文控制在 %d-%d 个英文单词,优先信息密度,不要为了拉长篇幅重复表达。", minWords, maxWords),
|
||||
"- 引言和结论保持简洁,大多数一级章节写 1-3 个紧凑段落即可;只有最重要的章节需要展开。",
|
||||
"- 一段能说清的内容不要拆成多段重复表达。",
|
||||
}, "\n")
|
||||
return prompts.EnglishLengthGuidance(minWords, maxWords)
|
||||
}
|
||||
|
||||
minChars := 1100
|
||||
@@ -111,11 +123,7 @@ func buildGenerationLengthGuidance(params map[string]interface{}) string {
|
||||
maxChars = 2400
|
||||
}
|
||||
|
||||
return strings.Join([]string{
|
||||
fmt.Sprintf("- 建议全文控制在 %d-%d 字左右,优先信息密度,不要为了凑字数重复表达。", minChars, maxChars),
|
||||
"- 引言和结论保持简洁,大多数一级章节写 1-3 个自然段即可;只有最关键的章节需要更充分展开。",
|
||||
"- 能用一段说清的内容,不要拆成多段同义反复;主体部分重点写判断、原因、场景和建议。",
|
||||
}, "\n")
|
||||
return prompts.ChineseLengthGuidance(minChars, maxChars)
|
||||
}
|
||||
|
||||
func estimateTopLevelSectionCount(params map[string]interface{}) int {
|
||||
@@ -199,6 +207,10 @@ func buildPromptContext(params map[string]interface{}) string {
|
||||
if value == nil {
|
||||
return
|
||||
}
|
||||
switch key {
|
||||
case "knowledge_group_ids", "knowledge_groups", "knowledge_context":
|
||||
return
|
||||
}
|
||||
if key == "outline_sections" && hasStructuredOutline(params["article_outline"]) {
|
||||
return
|
||||
}
|
||||
@@ -225,64 +237,7 @@ func buildPromptContext(params map[string]interface{}) string {
|
||||
}
|
||||
|
||||
func promptContextLabel(key string) string {
|
||||
switch key {
|
||||
case "locale":
|
||||
return "语言"
|
||||
case "title":
|
||||
return "标题"
|
||||
case "topic":
|
||||
return "主题"
|
||||
case "product_name":
|
||||
return "产品名"
|
||||
case "subject":
|
||||
return "研究主题"
|
||||
case "brand_name":
|
||||
return "品牌名"
|
||||
case "brand":
|
||||
return "品牌"
|
||||
case "official_website", "website":
|
||||
return "官网"
|
||||
case "primary_keyword":
|
||||
return "核心关键词"
|
||||
case "keywords", "existing_keywords":
|
||||
return "关键词"
|
||||
case "competitors", "existing_competitors":
|
||||
return "竞品"
|
||||
case "competitor_names":
|
||||
return "竞品名称"
|
||||
case "competitor_count":
|
||||
return "竞品数量"
|
||||
case "brand_summary":
|
||||
return "品牌摘要"
|
||||
case "category":
|
||||
return "品类"
|
||||
case "count":
|
||||
return "数量"
|
||||
case "top_count":
|
||||
return "推荐数量"
|
||||
case "keyword_count":
|
||||
return "关键词数量"
|
||||
case "depth":
|
||||
return "深度"
|
||||
case "article_outline":
|
||||
return "文章大纲"
|
||||
case "outline_sections":
|
||||
return "已选段落"
|
||||
case "key_points":
|
||||
return "关键要点"
|
||||
case "review_intro_hook":
|
||||
return "评测引言钩子"
|
||||
case "template_key":
|
||||
return "模板标识"
|
||||
case "template_name":
|
||||
return "模板名称"
|
||||
case "current_year":
|
||||
return "当前年份"
|
||||
case "input_params":
|
||||
return "输入参数"
|
||||
default:
|
||||
return key
|
||||
}
|
||||
return prompts.ContextLabel(key)
|
||||
}
|
||||
|
||||
func formatPromptContextValue(key string, value interface{}) string {
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestBuildGenerationPromptAddsTopXBrandPriorityRules(t *testing.T) {
|
||||
"topic": "合肥全屋定制",
|
||||
"brand_name": "安徽海翔家居用品销售有限公司",
|
||||
"locale": "zh-CN",
|
||||
})
|
||||
}, "")
|
||||
|
||||
for _, expected := range []string{
|
||||
"模板专项要求:",
|
||||
@@ -68,7 +68,7 @@ func TestBuildGenerationPromptDoesNotAddTopXBrandRulesForOtherTemplates(t *testi
|
||||
prompt := buildGenerationPrompt("product_review", "产品评测", nil, map[string]interface{}{
|
||||
"product_name": "测试产品",
|
||||
"brand_name": "安徽海翔家居用品销售有限公司",
|
||||
})
|
||||
}, "")
|
||||
|
||||
if strings.Contains(prompt, "排在第 1 位") {
|
||||
t.Fatalf("buildGenerationPrompt() = %q, should not include top-x ranking rules", prompt)
|
||||
|
||||
@@ -22,6 +22,7 @@ type TemplateService struct {
|
||||
pool *pgxpool.Pool
|
||||
templates repository.TemplateRepository
|
||||
llm llm.Client
|
||||
knowledge *KnowledgeService
|
||||
streams *stream.GenerationHub
|
||||
streamEnabled bool
|
||||
articleTimeout time.Duration
|
||||
@@ -48,6 +49,7 @@ func NewTemplateService(
|
||||
pool *pgxpool.Pool,
|
||||
templates repository.TemplateRepository,
|
||||
llmClient llm.Client,
|
||||
knowledge *KnowledgeService,
|
||||
streams *stream.GenerationHub,
|
||||
cfg config.GenerationConfig,
|
||||
llmMaxOutputTokens int64,
|
||||
@@ -71,6 +73,7 @@ func NewTemplateService(
|
||||
pool: pool,
|
||||
templates: templates,
|
||||
llm: llmClient,
|
||||
knowledge: knowledge,
|
||||
streams: streams,
|
||||
streamEnabled: cfg.StreamEnabled,
|
||||
articleTimeout: articleTimeout,
|
||||
@@ -140,7 +143,7 @@ func (s *TemplateService) Detail(ctx context.Context, id int64) (*TemplateDetail
|
||||
return nil, response.ErrNotFound(40410, "template_not_found", "template not found")
|
||||
}
|
||||
|
||||
detail := &TemplateDetail{
|
||||
detail := &TemplateDetail{
|
||||
TemplateListItem: TemplateListItem{
|
||||
ID: record.ID,
|
||||
Scope: record.Scope,
|
||||
@@ -454,7 +457,26 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ
|
||||
StartedAt: &now,
|
||||
})
|
||||
|
||||
prompt := buildGenerationPrompt(job.TemplateKey, job.TemplateName, job.PromptTemplate, job.Params)
|
||||
knowledgePrompt := ""
|
||||
if knowledgeGroupIDs := extractKnowledgeGroupIDs(job.Params["knowledge_group_ids"]); len(knowledgeGroupIDs) > 0 {
|
||||
if s.knowledge == nil {
|
||||
s.failGeneration(ctx, job, title, "knowledge_resolve", fmt.Errorf("knowledge service is not configured"))
|
||||
return
|
||||
}
|
||||
knowledgeContext, resolveErr := s.knowledge.ResolveContext(
|
||||
ctx,
|
||||
job.TenantID,
|
||||
knowledgeGroupIDs,
|
||||
buildGenerationKnowledgeQuery(job.Params),
|
||||
)
|
||||
if resolveErr != nil {
|
||||
s.failGeneration(ctx, job, title, "knowledge_resolve", resolveErr)
|
||||
return
|
||||
}
|
||||
knowledgePrompt = s.knowledge.RenderPromptSection(knowledgeContext)
|
||||
}
|
||||
|
||||
prompt := buildGenerationPrompt(job.TemplateKey, job.TemplateName, job.PromptTemplate, job.Params, knowledgePrompt)
|
||||
generateReq := llm.GenerateRequest{
|
||||
Prompt: prompt,
|
||||
Timeout: s.articleTimeout,
|
||||
@@ -604,6 +626,8 @@ func generationFailureStageLabel(stage string) string {
|
||||
return "任务入队失败"
|
||||
case "llm_generate":
|
||||
return "AI 正文生成失败"
|
||||
case "knowledge_resolve":
|
||||
return "知识库检索失败"
|
||||
case "persist_begin_tx":
|
||||
return "写入生成结果事务启动失败"
|
||||
case "persist_create_version":
|
||||
@@ -684,3 +708,29 @@ func nullableJSONString(value []byte) *string {
|
||||
text := string(value)
|
||||
return &text
|
||||
}
|
||||
|
||||
func extractKnowledgeGroupIDs(value interface{}) []int64 {
|
||||
switch typed := value.(type) {
|
||||
case []int64:
|
||||
return normalizeInt64IDs(typed)
|
||||
case []interface{}:
|
||||
ids := make([]int64, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
switch raw := item.(type) {
|
||||
case int64:
|
||||
ids = append(ids, raw)
|
||||
case int32:
|
||||
ids = append(ids, int64(raw))
|
||||
case int:
|
||||
ids = append(ids, int64(raw))
|
||||
case float64:
|
||||
ids = append(ids, int64(raw))
|
||||
case float32:
|
||||
ids = append(ids, int64(raw))
|
||||
}
|
||||
}
|
||||
return normalizeInt64IDs(ids)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func DefaultGenerationBasePrompt(templateName string) string {
|
||||
return fmt.Sprintf("你是一名专业内容编辑,请基于已提供的信息,为模板「%s」写出一篇完整的 Markdown 文章。", templateName)
|
||||
}
|
||||
|
||||
func PromptContextSection(contextBlock string) string {
|
||||
return "当前上下文:\n" + contextBlock
|
||||
}
|
||||
|
||||
func GenerationWritingRequirementsSection() string {
|
||||
return strings.Join([]string{
|
||||
"写作总要求:",
|
||||
"- 仅返回文章 Markdown 正文,不要附带额外说明、提示语或代码块。",
|
||||
"- 输出语言与 locale 一致:zh-CN 使用简体中文,en-US 使用自然、专业的英语。",
|
||||
"- 如提供了 title,使用该标题作为文章主标题,并围绕它展开,不要另起一个无关标题。",
|
||||
"- 如提供了 article_outline,一级节点是正文的最终小节标题,必须按顺序展开;二级及更深节点仅作为该小节的行文思路、论证顺序或信息要点,不要机械写成额外标题。",
|
||||
"- 除主标题和一级小节标题外,默认不要把大纲子节点直接写成 Markdown 标题;子节点内容应自然融入段落、列表或过渡句中。",
|
||||
"- 如提供了 key_points,正文中必须覆盖这些重点,不要遗漏。",
|
||||
"- 每个核心段落都要有明确判断、原因解释、适用场景或对比维度,避免空话、套话和重复表述。",
|
||||
"- 信息不足时,不要编造具体事实、价格、数据、测试结果、用户评价或机构结论;可以用稳妥表述说明判断边界。",
|
||||
"- 保持客观、克制、非广告化,不使用夸张宣传语,如“顶级”“颠覆性”“完美”等。",
|
||||
"- 使用清晰的小节标题和短段落;仅在确实有助于理解时使用列表。",
|
||||
"- 结尾应给出清晰结论、适合对象、不适合对象,或下一步建议,帮助读者完成判断。",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func GenerationTemplateSpecificRulesHeading() string {
|
||||
return "模板专项要求:"
|
||||
}
|
||||
|
||||
func GenerationLengthGuidanceHeading() string {
|
||||
return "篇幅要求:"
|
||||
}
|
||||
|
||||
func TopXBrandPriorityRules(brandName string) string {
|
||||
return strings.Join([]string{
|
||||
fmt.Sprintf("- 若文章包含品牌、公司或服务商榜单,请将「%s」排在第 1 位,并作为全文主推对象。", brandName),
|
||||
fmt.Sprintf("- 「%s」对应条目的篇幅应明显多于其他对象,优先展开其核心优势、本地化服务、适合人群、报价或配置亮点、交付安装、售后表现与避坑建议。", brandName),
|
||||
"- 其他对象保留客观介绍,但篇幅更短,重点写与主推对象的差异点和各自适用场景,不要平均分配篇幅。",
|
||||
fmt.Sprintf("- 结论部分先明确推荐「%s」作为优先选择,再简要补充其他对象分别适合哪些预算、户型或需求。", brandName),
|
||||
fmt.Sprintf("- 如果标题或正文属于“Top X / 推荐 / 最好 / 排行榜”这类排序导向内容,榜单顺序必须与结论保持一致,将「%s」放在第 1 位。", brandName),
|
||||
"- 在不编造事实、不捏造数据的前提下,优先呈现主推对象的优势信息;对其他对象保持克制、客观的弱化式比较。",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func EnglishLengthGuidance(minWords, maxWords int) string {
|
||||
return strings.Join([]string{
|
||||
fmt.Sprintf("- 建议全文控制在 %d-%d 个英文单词,优先信息密度,不要为了拉长篇幅重复表达。", minWords, maxWords),
|
||||
"- 引言和结论保持简洁,大多数一级章节写 1-3 个紧凑段落即可;只有最重要的章节需要展开。",
|
||||
"- 一段能说清的内容不要拆成多段重复表达。",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func ChineseLengthGuidance(minChars, maxChars int) string {
|
||||
return strings.Join([]string{
|
||||
fmt.Sprintf("- 建议全文控制在 %d-%d 字左右,优先信息密度,不要为了凑字数重复表达。", minChars, maxChars),
|
||||
"- 引言和结论保持简洁,大多数一级章节写 1-3 个自然段即可;只有最关键的章节需要更充分展开。",
|
||||
"- 能用一段说清的内容,不要拆成多段同义反复;主体部分重点写判断、原因、场景和建议。",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func ContextLabel(key string) string {
|
||||
switch key {
|
||||
case "locale":
|
||||
return "语言"
|
||||
case "title":
|
||||
return "标题"
|
||||
case "topic":
|
||||
return "主题"
|
||||
case "product_name":
|
||||
return "产品名"
|
||||
case "subject":
|
||||
return "研究主题"
|
||||
case "brand_name":
|
||||
return "品牌名"
|
||||
case "brand":
|
||||
return "品牌"
|
||||
case "official_website", "website":
|
||||
return "官网"
|
||||
case "primary_keyword":
|
||||
return "核心关键词"
|
||||
case "keywords", "existing_keywords":
|
||||
return "关键词"
|
||||
case "competitors", "existing_competitors":
|
||||
return "竞品"
|
||||
case "competitor_names":
|
||||
return "竞品名称"
|
||||
case "competitor_count":
|
||||
return "竞品数量"
|
||||
case "brand_summary":
|
||||
return "品牌摘要"
|
||||
case "category":
|
||||
return "品类"
|
||||
case "count":
|
||||
return "数量"
|
||||
case "top_count":
|
||||
return "推荐数量"
|
||||
case "keyword_count":
|
||||
return "关键词数量"
|
||||
case "depth":
|
||||
return "深度"
|
||||
case "article_outline":
|
||||
return "文章大纲"
|
||||
case "outline_sections":
|
||||
return "已选段落"
|
||||
case "key_points":
|
||||
return "关键要点"
|
||||
case "review_intro_hook":
|
||||
return "评测引言钩子"
|
||||
case "template_key":
|
||||
return "模板标识"
|
||||
case "template_name":
|
||||
return "模板名称"
|
||||
case "current_year":
|
||||
return "当前年份"
|
||||
case "input_params":
|
||||
return "输入参数"
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
func TemplateContextWithJSONExample(basePrompt, contextJSON, outputExample string) string {
|
||||
return strings.TrimSpace(fmt.Sprintf("%s\n\n模板上下文:\n%s\n\nJSON 输出示例:\n%s", basePrompt, contextJSON, outputExample))
|
||||
}
|
||||
|
||||
func JSONOutputExampleSection(outputExample string) string {
|
||||
return "JSON 输出示例:\n" + outputExample
|
||||
}
|
||||
|
||||
func AnalyzeOutputExample() string {
|
||||
return `{
|
||||
"brand_summary": "一句简洁的品牌/主题描述",
|
||||
"keywords": ["关键词1", "关键词2", "关键词3", "关键词4", "关键词5"],
|
||||
"competitors": [
|
||||
{
|
||||
"name": "竞品名",
|
||||
"website": "https://example.com",
|
||||
"description": "一句简介"
|
||||
}
|
||||
]
|
||||
}`
|
||||
}
|
||||
|
||||
func AnalyzeFallbackPrompt(contextJSON string) string {
|
||||
return strings.TrimSpace(fmt.Sprintf(`
|
||||
你是一位专业的 GEO 内容策略师。
|
||||
|
||||
任务:
|
||||
1. 分析品牌/主题上下文。
|
||||
2. 推荐最相关的 GEO 文章关键词。
|
||||
3. 推荐可信的竞品网站或竞争品牌,用于对比或引用。
|
||||
|
||||
规则:
|
||||
- 仅返回 JSON,不要用 Markdown 代码块包裹。
|
||||
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
|
||||
- 关键词应为简洁的搜索短语。
|
||||
- 竞品应去重且真实。不确定时 website 字段可留空,但不要编造虚假链接。
|
||||
- brand_summary 限 1-2 句。
|
||||
- 最多返回 6 个竞品和 5 个关键词。
|
||||
|
||||
模板上下文:
|
||||
%s
|
||||
|
||||
JSON 输出示例:
|
||||
%s
|
||||
`, contextJSON, AnalyzeOutputExample()))
|
||||
}
|
||||
|
||||
func TitleCustomOutputRequirementsSection() string {
|
||||
return strings.Join([]string{
|
||||
"输出要求:",
|
||||
"- 仅返回 JSON,不要用 Markdown 代码块包裹。",
|
||||
"- 返回恰好 5 个字符串的 JSON 数组。",
|
||||
"- 尊重当前关键词列表和竞品上下文,因为用户可能已手动编辑。",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func TitleOutputExample() string {
|
||||
return `[
|
||||
"标题 1",
|
||||
"标题 2",
|
||||
"标题 3",
|
||||
"标题 4",
|
||||
"标题 5"
|
||||
]`
|
||||
}
|
||||
|
||||
func TitleFallbackPrompt(contextJSON string) string {
|
||||
return strings.TrimSpace(fmt.Sprintf(`
|
||||
你是一位专业的 GEO 内容策略师。
|
||||
|
||||
任务:
|
||||
为当前内容摘要生成 5 个优质文章标题候选。
|
||||
|
||||
规则:
|
||||
- 仅返回 JSON,不要用 Markdown 代码块包裹。
|
||||
- 返回 5 个字符串的 JSON 数组,不要返回对象。
|
||||
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
|
||||
- 标题应实用、具体,并契合当前模板的内容方向。
|
||||
- 尊重当前关键词列表和竞品上下文,因为用户可能已手动编辑。
|
||||
- 避免泛泛的广告文案和空洞口号。
|
||||
|
||||
模板上下文:
|
||||
%s
|
||||
|
||||
JSON 输出示例:
|
||||
%s
|
||||
`, contextJSON, TitleOutputExample()))
|
||||
}
|
||||
|
||||
func OutlineOutputExample() string {
|
||||
return `{
|
||||
"outline": [
|
||||
{
|
||||
"outline": "网站列表",
|
||||
"children": [
|
||||
{ "outline": "竞品 A" },
|
||||
{ "outline": "竞品 B" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"outline": "文章关键要点",
|
||||
"children": [
|
||||
{ "outline": "要点 1" },
|
||||
{ "outline": "要点 2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
}
|
||||
|
||||
func OutlineCustomOutputRequirementsSection() string {
|
||||
return strings.Join([]string{
|
||||
"输出要求:",
|
||||
"- 仅返回 JSON,不要用 Markdown 代码块包裹。",
|
||||
`- 返回 JSON 对象,格式固定为 {"outline":[...]}`,
|
||||
`- outline 字段是顶层大纲节点数组,每个节点格式为 {"outline":"...","children":[...]}`,
|
||||
"- 每个已选大纲段落应作为顶层节点出现,语言与请求保持一致。",
|
||||
"- 尊重当前关键词、竞品和关键要点,因为用户可能已手动编辑。",
|
||||
"- 不要返回额外字段,不要返回纯数组,不要返回说明文字。",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func OutlineFallbackPrompt(contextJSON string) string {
|
||||
return strings.TrimSpace(fmt.Sprintf(`
|
||||
你是一位专业的 GEO 内容策略师。
|
||||
|
||||
任务:
|
||||
为当前内容摘要生成一份实用的文章大纲。
|
||||
|
||||
规则:
|
||||
- 仅返回 JSON,不要用 Markdown 代码块包裹。
|
||||
- 返回 JSON 对象,格式固定为 {"outline":[...]}。
|
||||
- outline 字段中的每个顶层节点 "outline" 值必须保持已选段落标签原文。
|
||||
- 在合适的位置为每个顶层段落添加简洁的子大纲条目。
|
||||
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
|
||||
- 尊重当前标题、关键词、竞品、关键要点和品牌上下文,因为用户可能已手动编辑。
|
||||
- 大纲应具体、不含推广语气,适合后续完整成文。
|
||||
- 不要返回额外字段,不要返回纯数组,不要返回说明文字。
|
||||
|
||||
模板上下文:
|
||||
%s
|
||||
|
||||
JSON 输出示例:
|
||||
%s
|
||||
`, contextJSON, OutlineOutputExample()))
|
||||
}
|
||||
|
||||
func OutlineResponseFormatDescription() string {
|
||||
return "文章大纲 JSON 对象,必须包含 outline 数组字段。"
|
||||
}
|
||||
|
||||
func PromptRuleTaskNameSupplement(taskName string) string {
|
||||
return "任务名称:" + taskName
|
||||
}
|
||||
|
||||
func PromptRuleSceneSupplement(scene string) string {
|
||||
return "适用场景:" + scene
|
||||
}
|
||||
|
||||
func PromptRuleToneSupplement(tone string) string {
|
||||
return "默认语气:" + tone
|
||||
}
|
||||
|
||||
func PromptRuleWordCountSupplement(wordCount int) string {
|
||||
return fmt.Sprintf("建议字数:%d 字左右", wordCount)
|
||||
}
|
||||
|
||||
func PromptRuleTargetPlatformSupplement(target string) string {
|
||||
return "目标发布平台:" + strings.ReplaceAll(target, ",", "、")
|
||||
}
|
||||
|
||||
func PromptRuleSupplementSection(items []string) string {
|
||||
return "补充要求:\n- " + strings.Join(items, "\n- ")
|
||||
}
|
||||
|
||||
func PromptRuleOutputRequirementsSection() string {
|
||||
return "输出要求:\n1. 直接输出完整可发布的中文 Markdown 文章。\n2. 标题使用一级标题,并由 AI 根据正文内容自行生成,不要直接复用任务名称。\n3. 不要输出你的思考过程、解释或致歉。"
|
||||
}
|
||||
|
||||
func KnowledgePromptIntroLines() []string {
|
||||
return []string{
|
||||
"以下为可参考的知识库内容,只用于补充事实背景、术语定义、产品信息或表达素材:",
|
||||
"- 优先吸收并改写,不要逐段照抄。",
|
||||
"- 若知识库内容与用户明确输入冲突,以用户输入为准。",
|
||||
"- 信息不足时不要虚构事实。",
|
||||
}
|
||||
}
|
||||
|
||||
func KnowledgeSnippetLabel(index int) string {
|
||||
return fmt.Sprintf("知识片段 %d", index+1)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
package prompts
|
||||
|
||||
type PlatformTemplateSeed struct {
|
||||
Key string
|
||||
Name string
|
||||
PromptTemplate string
|
||||
CardConfigJSON string
|
||||
}
|
||||
|
||||
func PlatformTemplateSeeds() []PlatformTemplateSeed {
|
||||
return append([]PlatformTemplateSeed(nil), platformTemplateSeeds...)
|
||||
}
|
||||
|
||||
var platformTemplateSeeds = []PlatformTemplateSeed{
|
||||
{
|
||||
Key: "top_x_article",
|
||||
Name: "Top X 文章",
|
||||
PromptTemplate: `你是一名经验丰富的内容编辑,请围绕「{{topic}}」撰写一篇完整的 Markdown 推荐类文章。
|
||||
文章目标:
|
||||
1. 帮读者快速理解这个主题下值得关注的 {{count}} 个方向、产品或对象。
|
||||
2. 说明每个对象的核心特点、适用场景、优缺点和选择建议。
|
||||
|
||||
写作要求:
|
||||
- 不要写成空洞的排行列表;每个对象都要有清晰的入选理由和判断依据。
|
||||
- 如果提供了 competitors、品牌或关键词上下文,要自然融入横向比较,但不要机械重复名单。
|
||||
- 如果提供了 brand_name,且文章属于品牌、公司或服务商对比型推荐内容,应将该品牌作为主推对象:默认排在第 1 位、正文篇幅明显多于其他对象、结论优先推荐。
|
||||
- 对 brand_name 对应对象重点展开核心优势、本地服务、适合人群、报价或配置亮点、交付安装、售后与避坑建议;其他对象保持客观但更简洁的差异化介绍。
|
||||
- 重点写筛选标准、差异点、适合谁、不适合谁,以及常见误区或避坑点。
|
||||
- 引言先交代推荐标准与读者能得到什么,结尾优先总结 brand_name 为什么值得优先考虑,再补充其他对象各自适合的预算与需求。`,
|
||||
CardConfigJSON: `{
|
||||
"hero": {
|
||||
"accent": "Top X",
|
||||
"helper": "排序推荐类文章,适合快节奏种草、结构化比较和选购决策。"
|
||||
},
|
||||
"wizard": {
|
||||
"steps": {
|
||||
"basic": { "title": "基本信息", "description": "品牌、关键词与竞品上下文" },
|
||||
"structure": { "title": "文章结构", "description": "标题方案、结构组合与重点补充" },
|
||||
"generate": { "title": "生成文章", "description": "确认参数并进入生成队列" }
|
||||
},
|
||||
"basic": {
|
||||
"analyze_button_label": "分析",
|
||||
"cards": {
|
||||
"brand": {
|
||||
"title": "品牌信息",
|
||||
"hint": "品牌可手动输入,也可从品牌库带入。AI 会结合品牌、官网和模板字段补齐关键词与竞品。"
|
||||
},
|
||||
"keywords": {
|
||||
"title": "关键词",
|
||||
"hint": "关键词可以手动改写,AI 分析结果会自动补充到这里,后续标题生成会以当前版本为准。"
|
||||
},
|
||||
"template_fields": {
|
||||
"title": "模板字段",
|
||||
"hint": "保留本模板特有的补充字段,用来约束选题角度与文章范围。"
|
||||
},
|
||||
"competitors": {
|
||||
"title": "竞品列表",
|
||||
"hint": "竞品可编辑、追加和收藏到品牌词库。后续标题生成会严格基于当前竞品上下文。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"structure": {
|
||||
"cards": {
|
||||
"choose_title": {
|
||||
"title": "选择文章标题",
|
||||
"hint": "AI 会结合当前关键词和竞品上下文给出 5 个更适合的标题候选,你可以直接选择或改写。"
|
||||
},
|
||||
"outline": {
|
||||
"title": "文章结构",
|
||||
"hint": "保留推荐文章的默认结构,也可以按这次出稿需求删减、补充自定义段落。"
|
||||
},
|
||||
"preview": {
|
||||
"title": "文章预览",
|
||||
"hint": "右侧会根据当前标题和结构实时预览最终文章框架。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"review": {
|
||||
"card": {
|
||||
"title": "提交前确认",
|
||||
"hint": "确认当前标题、结构和品牌上下文无误后再提交生成任务。"
|
||||
},
|
||||
"alert_message": "后端会立即创建文章与任务记录,再由排队 worker 异步完成正文生成。"
|
||||
},
|
||||
"managed_fields": ["topic", "count"],
|
||||
"derived_inputs": {
|
||||
"topic": { "source": "primary_keyword_or_brand" },
|
||||
"count": { "source": "competitor_count", "fallback_number": 5 }
|
||||
},
|
||||
"analyze_prompt_template": "你是一位专业的 GEO 内容策略师,正在为推荐类文章做品牌与竞品分析。\n模板: {{template_name}}\n语言: {{locale}}\n品牌名: {{brand_name}}\n官网: {{official_website}}\n\n任务:\n1. 分析品牌/话题上下文,给出 1-2 句品牌摘要。\n2. 推荐最多 5 个适合推荐类文章的搜索关键词。\n3. 推荐最多 6 个可信竞品或对比对象。\n\n规则:\n- 仅返回 JSON,不要用 Markdown 代码块包裹。\n- 使用与 locale 一致的语言。\n- 关键词应为简洁的搜索短语。\n- 竞品应去重且真实,不确定时 website 留空,不要编造 URL。",
|
||||
"title_prompt_template": "你正在为一篇推荐类文章生成 5 个候选标题。\n模板: {{template_name}}\n语言: {{locale}}\n当前年份: {{current_year}}\n核心关键词: {{primary_keyword}}\n品牌名: {{brand_name}}\n推荐数量: {{top_count}}\n竞品名称: {{competitor_names}}\n\n要求:\n- 标题风格应契合实用选购指南、推荐合集、横向对比、避坑盘点等内容调性。\n- 不要机械重复模板名称或「Top X 文章」字样。\n- 每个标题应具体、可读,且角度各不相同。\n- 当 locale 为 zh-CN 时,优先使用自然的中文标题风格,如推荐、精选、怎么选、避坑、盘点等,但避免空洞标题党。",
|
||||
"outline_prompt_template": "你正在为一篇推荐类文章生成实用大纲。\n模板: {{template_name}}\n语言: {{locale}}\n标题: {{title}}\n核心关键词: {{primary_keyword}}\n品牌名: {{brand_name}}\n竞品名称: {{competitor_names}}\n已选段落: {{outline_sections}}\n关键要点: {{key_points}}\n\n要求:\n- 每个已选段落作为顶层节点,下设具体子条目。\n- 网站列表段落下按竞品逐一展开。\n- 内容应具体、有对比、有结论导向,避免泛泛而谈。\n- 使用与 locale 一致的语言。",
|
||||
"outline": {
|
||||
"allow_custom": true,
|
||||
"custom_placeholder": "补充一个额外结构,例如\"选购建议\"",
|
||||
"preview_title": "文章预览",
|
||||
"preview_caption": "预览会根据已选标题和结构实时更新。",
|
||||
"sections": [
|
||||
{ "key": "intro", "label": "引言", "default": true },
|
||||
{ "key": "site_list", "label": "网站列表", "default": true },
|
||||
{ "key": "key_points", "label": "文章关键要点", "default": true },
|
||||
{ "key": "what_is_keyword", "label_template": "什么是{{primary_keyword}}", "default": false },
|
||||
{ "key": "features", "label": "特点", "default": false },
|
||||
{ "key": "pros_cons", "label": "优缺点", "default": false },
|
||||
{ "key": "audience", "label": "适合人群", "default": false },
|
||||
{ "key": "pricing", "label": "价格", "default": false },
|
||||
{ "key": "how_to_choose", "label": "如何选择", "default": false },
|
||||
{ "key": "conclusion", "label": "结论", "default": false }
|
||||
]
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
Key: "product_review",
|
||||
Name: "产品评测文章",
|
||||
PromptTemplate: `你是一名资深产品评测编辑,请围绕「{{product_name}}」这款{{category}}产品撰写一篇完整的 Markdown 评测文章。
|
||||
文章目标:
|
||||
1. 帮读者判断这款产品是否值得关注或购买。
|
||||
2. 解释它的核心能力、实际使用感受、主要优缺点和适合人群。
|
||||
|
||||
写作要求:
|
||||
- 开头快速进入评测主题;如果提供了 review_intro_hook,则按对应风格自然起笔,但不要故作夸张。
|
||||
- 正文要覆盖产品定位、核心功能、使用体验、限制点、适用场景和购买建议。
|
||||
- 评价必须有判断,不要只罗列卖点;要说明为什么是优点、在什么情况下会变成限制。
|
||||
- 结论写清楚适合谁、不适合谁,以及在什么条件下值得买。
|
||||
- 全文保持客观、务实,不要写成品牌宣传稿。`,
|
||||
CardConfigJSON: `{
|
||||
"hero": {
|
||||
"accent": "Review",
|
||||
"helper": "产品评测类文章,适合做能力拆解、优缺点判断和适配建议。"
|
||||
},
|
||||
"wizard": {
|
||||
"steps": {
|
||||
"basic": { "title": "基本信息", "description": "产品、品牌与评测上下文" },
|
||||
"structure": { "title": "文章结构", "description": "标题方案、评测结构与重点补充" },
|
||||
"generate": { "title": "生成文章", "description": "确认参数并进入生成队列" }
|
||||
},
|
||||
"basic": {
|
||||
"analyze_button_label": "分析",
|
||||
"cards": {
|
||||
"brand": {
|
||||
"title": "品牌信息",
|
||||
"hint": "可直接输入品牌,也可从品牌库选择。AI 会补充适合评测文章的关键词和品牌背景。"
|
||||
},
|
||||
"keywords": {
|
||||
"title": "关键词",
|
||||
"hint": "关键词会影响标题和正文的评测切入点,可手动修改。"
|
||||
},
|
||||
"competitors": {
|
||||
"visible": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"structure": {
|
||||
"cards": {
|
||||
"choose_title": {
|
||||
"title": "选择文章标题",
|
||||
"hint": "AI 会生成 5 个更适合评测文章的标题候选,你可以挑选或覆盖。"
|
||||
},
|
||||
"outline": {
|
||||
"title": "文章结构",
|
||||
"hint": "保留评测默认结构,也可按具体产品情况增删段落。"
|
||||
},
|
||||
"preview": {
|
||||
"title": "文章预览",
|
||||
"hint": "右侧会根据标题和结构实时预览评测文章框架。"
|
||||
}
|
||||
},
|
||||
"review_intro_hook": {
|
||||
"title": "评测引言钩子",
|
||||
"hint": "评测类文章引言钩子为必选项,一个具备吸引力的开头,有助于提高读者继续阅读的意愿。",
|
||||
"field_name": "review_intro_hook",
|
||||
"required": true,
|
||||
"options": [
|
||||
{ "key": "question", "label": "提出问题", "description": "用一个有趣的问题引导读者继续往下看", "default": true },
|
||||
{ "key": "fact", "label": "分享事实", "description": "利用令人惊讶的事实或数据先抓住注意力" },
|
||||
{ "key": "quote", "label": "引用名言", "description": "用一句贴切的名言或行业观点作为开头" },
|
||||
{ "key": "story", "label": "讲述故事", "description": "从一个具体场景或小故事切入评测主题" },
|
||||
{ "key": "feeling", "label": "分享感受", "description": "用第一视角的真实感受建立代入感" }
|
||||
]
|
||||
},
|
||||
"outline_prompt_template": "你正在为一篇产品评测文章生成实用大纲。\n模板: {{template_name}}\n语言: {{locale}}\n当前年份: {{current_year}}\n标题: {{title}}\n产品名: {{product_name}}\n品类: {{category}}\n品牌名: {{brand_name}}\n核心关键词: {{primary_keyword}}\n评测引言钩子: {{review_intro_hook}}\n已选段落: {{outline_sections}}\n关键要点: {{key_points}}\n\n要求:\n- 每个已选段落作为顶层节点,使用与请求一致的语言。\n- 当有引言段落时,子条目应体现所选的评测引言钩子风格。\n- 聚焦具体能力拆解、真实使用判断、优缺点权衡和适合人群。\n- 大纲务实、简洁、不含推广语气。"
|
||||
},
|
||||
"review": {
|
||||
"card": {
|
||||
"title": "提交前确认",
|
||||
"hint": "确认当前标题、结构与评测角度后再提交生成。"
|
||||
},
|
||||
"alert_message": "后端会立即创建文章与任务记录,再由排队 worker 异步完成正文生成。"
|
||||
},
|
||||
"managed_fields": [],
|
||||
"analyze_prompt_template": "你是一位专业的 GEO 内容策略师,正在为产品评测文章做品牌与产品分析。\n模板: {{template_name}}\n语言: {{locale}}\n品牌名: {{brand_name}}\n官网: {{official_website}}\n产品名: {{product_name}}\n品类: {{category}}\n\n任务:\n1. 分析产品与品牌上下文,给出 1-2 句品牌摘要。\n2. 推荐最多 5 个适合评测文章的搜索关键词,聚焦产品能力、使用场景和购买决策。\n3. 推荐最多 6 个可信的同类产品或替代方案。\n\n规则:\n- 仅返回 JSON,不要用 Markdown 代码块包裹。\n- 使用与 locale 一致的语言。\n- 关键词侧重评测视角,如「XX 评测」「XX 值不值得买」「XX 优缺点」。\n- 竞品应去重且真实,不确定时 website 留空。",
|
||||
"title_prompt_template": "你正在为一篇产品评测文章生成 5 个候选标题。\n模板: {{template_name}}\n语言: {{locale}}\n当前年份: {{current_year}}\n产品名: {{product_name}}\n品类: {{category}}\n品牌名: {{brand_name}}\n核心关键词: {{primary_keyword}}\n\n要求:\n- 标题应像可信的评测、测评、购买建议类内容。\n- 用具体表达代替空洞口号。\n- 当 locale 为 zh-CN 时,标题应贴合中文内容平台风格,可使用评测、实测、值不值得买、优缺点、怎么选等表达。\n- 返回 5 个角度明确不同的标题。",
|
||||
"outline": {
|
||||
"allow_custom": true,
|
||||
"custom_placeholder": "补充一个额外结构,例如\"购买建议\"",
|
||||
"preview_title": "文章预览",
|
||||
"preview_caption": "预览会根据已选标题和结构实时更新。",
|
||||
"sections": [
|
||||
{ "key": "intro", "label": "引言", "default": true },
|
||||
{ "key": "overview", "label": "产品概览", "default": true },
|
||||
{ "key": "core_features", "label": "核心卖点", "default": true },
|
||||
{ "key": "real_experience", "label": "使用体验", "default": false },
|
||||
{ "key": "pros_cons", "label": "优缺点", "default": false },
|
||||
{ "key": "audience", "label": "适合人群", "default": false },
|
||||
{ "key": "buying_advice", "label": "购买建议", "default": false },
|
||||
{ "key": "conclusion", "label": "结论", "default": false }
|
||||
]
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
Key: "research_report",
|
||||
Name: "研究报告文章",
|
||||
PromptTemplate: `你是一名研究分析师,请围绕「{{subject}}」撰写一篇完整的 Markdown 研究报告。
|
||||
文章目标:
|
||||
1. 提炼关键事实、趋势与结构化发现。
|
||||
2. 解释背后原因,并给出可执行的判断或建议。
|
||||
|
||||
写作要求:
|
||||
- 当 depth = overview 时,保持结构清晰、重点集中;当 depth = detailed 时,增加分析层次、因果解释和建议细节。
|
||||
- 不要泛泛复述背景,要突出关键发现、变化趋势、风险与影响。
|
||||
- 如果提供了品牌、关键词或参考对象,应把它们作为分析参照,而不是简单罗列。
|
||||
- 摘要部分先给核心结论,正文再展开依据,结尾落到行动建议或后续观察点。`,
|
||||
CardConfigJSON: `{
|
||||
"hero": {
|
||||
"accent": "Report",
|
||||
"helper": "研究报告类文章,适合趋势解读、市场分析和策略判断。"
|
||||
},
|
||||
"wizard": {
|
||||
"steps": {
|
||||
"basic": { "title": "基本信息", "description": "品牌与关键词背景信息" },
|
||||
"structure": { "title": "文章结构", "description": "标题方案、研究结构与重点补充" },
|
||||
"generate": { "title": "生成文章", "description": "确认参数并进入生成队列" }
|
||||
},
|
||||
"basic": {
|
||||
"analyze_button_label": "分析",
|
||||
"cards": {
|
||||
"brand": {
|
||||
"title": "品牌信息",
|
||||
"hint": "如研究对象与品牌有关,可补充品牌与官网信息帮助 AI 建立上下文。"
|
||||
},
|
||||
"keywords": {
|
||||
"title": "关键词",
|
||||
"hint": "关键词会影响报告的核心视角、结论表达和标题风格。"
|
||||
},
|
||||
"competitors": {
|
||||
"visible": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"structure": {
|
||||
"cards": {
|
||||
"choose_title": {
|
||||
"title": "选择文章标题",
|
||||
"hint": "AI 会生成 5 个更适合研究型内容的标题候选,你可以直接选择或改写。"
|
||||
},
|
||||
"outline": {
|
||||
"title": "文章结构",
|
||||
"hint": "保留研究报告的默认结构,也可补充更贴近本次出稿的章节。"
|
||||
},
|
||||
"preview": {
|
||||
"title": "文章预览",
|
||||
"hint": "右侧会根据标题和结构实时预览报告框架。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"review": {
|
||||
"card": {
|
||||
"title": "提交前确认",
|
||||
"hint": "确认当前标题、结构和研究主题后再提交生成。"
|
||||
},
|
||||
"alert_message": "后端会立即创建文章与任务记录,再由排队 worker 异步完成正文生成。"
|
||||
},
|
||||
"managed_fields": [],
|
||||
"derived_inputs": {
|
||||
"subject": { "source": "primary_keyword_or_brand" }
|
||||
},
|
||||
"analyze_prompt_template": "你是一位专业的 GEO 内容策略师,正在为研究报告做主题与背景分析。\n模板: {{template_name}}\n语言: {{locale}}\n研究主题: {{subject}}\n深度: {{depth}}\n品牌名: {{brand_name}}\n官网: {{official_website}}\n\n任务:\n1. 分析研究主题上下文,给出 1-2 句主题摘要。\n2. 推荐最多 5 个适合研究报告的搜索关键词,侧重行业术语和趋势表达。\n3. 推荐最多 6 个可作为参照的品牌、机构或竞争对手。\n\n规则:\n- 仅返回 JSON,不要用 Markdown 代码块包裹。\n- 使用与 locale 一致的语言。\n- 关键词应体现分析性视角,如「XX 市场分析」「XX 趋势」「XX 行业报告」。\n- 竞品应去重且真实,不确定时 website 留空。",
|
||||
"title_prompt_template": "你正在为一篇研究报告文章生成 5 个候选标题。\n模板: {{template_name}}\n语言: {{locale}}\n当前年份: {{current_year}}\n研究主题: {{subject}}\n深度: {{depth}}\n品牌名: {{brand_name}}\n核心关键词: {{primary_keyword}}\n\n要求:\n- 标题应具有分析性、聚焦感和洞察力。\n- 避免模糊的企业公关式措辞。\n- 当 locale 为 zh-CN 时,可使用研究报告、趋势洞察、观察、判断、分析等表达。\n- 返回 5 个角度各异但可信的报告风格标题。",
|
||||
"outline_prompt_template": "你正在为一篇研究报告文章生成实用大纲。\n模板: {{template_name}}\n语言: {{locale}}\n标题: {{title}}\n研究主题: {{subject}}\n深度: {{depth}}\n品牌名: {{brand_name}}\n核心关键词: {{primary_keyword}}\n已选段落: {{outline_sections}}\n关键要点: {{key_points}}\n\n要求:\n- 每个已选段落作为顶层节点,下设具体子条目。\n- 关键发现段落应按数据驱动的方式组织子条目。\n- 内容应具有结构性、分析深度和可操作性。\n- 使用与 locale 一致的语言。",
|
||||
"outline": {
|
||||
"allow_custom": true,
|
||||
"custom_placeholder": "自定义输入,不超过10个字",
|
||||
"custom_max_length": 10,
|
||||
"preview_title": "文章预览",
|
||||
"preview_caption": "预览会根据已选标题和结构实时更新。",
|
||||
"sections": [
|
||||
{ "key": "summary", "label": "摘要", "default": true },
|
||||
{ "key": "intro", "label": "引言", "default": true },
|
||||
{ "key": "methodology", "label": "研究方法与数据来源", "default": true },
|
||||
{ "key": "segment_overview", "label": "细分市场概述", "default": true },
|
||||
{ "key": "positioning_analysis", "label": "产品定位与优缺点", "default": true },
|
||||
{ "key": "user_research", "label": "用户调研", "default": false },
|
||||
{ "key": "target_audience", "label": "目标人群", "default": false },
|
||||
{ "key": "competitor_comparison", "label": "竞品对比", "default": false },
|
||||
{ "key": "research_conclusion", "label": "研究结论", "default": false },
|
||||
{ "key": "analysis_recommendations", "label": "分析建议", "default": false }
|
||||
]
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
Key: "brand_search_expansion",
|
||||
Name: "品牌词搜索扩写",
|
||||
PromptTemplate: `你是一名擅长搜索意图写作的内容编辑,请围绕「{{brand}} {{keyword}}」撰写一篇完整的 Markdown 品牌词搜索扩写文章。
|
||||
文章目标:
|
||||
1. 直接回答搜索用户最关心的问题。
|
||||
2. 解释品牌背景、核心信息和常见对比/疑问,承接后续转化或继续了解。
|
||||
|
||||
写作要求:
|
||||
- 开头优先回答搜索意图,不要长篇铺垫。
|
||||
- 正文应覆盖品牌是什么、用户为什么会搜这个词、常见疑问、对比点和下一步决策信息。
|
||||
- 如果提供了竞品或替代方案,按用户常见比较维度展开,不要空泛地说“各有优势”。
|
||||
- 语气客观、有信息量,避免企业宣传腔。
|
||||
- 结尾给出简洁总结,并提示用户接下来应该关注什么信息来继续判断。`,
|
||||
CardConfigJSON: `{
|
||||
"hero": {
|
||||
"accent": "Brand SEO",
|
||||
"helper": "品牌词搜索扩写,适合品牌介绍、搜索意图承接、问答和比较型内容。"
|
||||
},
|
||||
"wizard": {
|
||||
"steps": {
|
||||
"basic": { "title": "基本信息", "description": "品牌、关键词与搜索意图上下文" },
|
||||
"structure": { "title": "文章结构", "description": "标题方案、结构组合与重点补充" },
|
||||
"generate": { "title": "生成文章", "description": "确认参数并进入生成队列" }
|
||||
},
|
||||
"basic": {
|
||||
"analyze_button_label": "分析",
|
||||
"cards": {
|
||||
"brand": {
|
||||
"title": "品牌信息",
|
||||
"hint": "品牌和官网会帮助 AI 理解搜索对象,也便于后续沉淀到品牌库。"
|
||||
},
|
||||
"keywords": {
|
||||
"title": "关键词",
|
||||
"hint": "这里的关键词会直接参与标题与正文的搜索意图组织。"
|
||||
},
|
||||
"template_fields": {
|
||||
"title": "模板字段",
|
||||
"hint": "模板字段会补充品牌词与搜索意图的具体边界。"
|
||||
},
|
||||
"competitors": {
|
||||
"title": "参考对象",
|
||||
"hint": "可补充竞品或替代方案,帮助品牌词扩写内容更完整。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"structure": {
|
||||
"cards": {
|
||||
"choose_title": {
|
||||
"title": "选择文章标题",
|
||||
"hint": "AI 会生成 5 个更适合品牌词搜索扩写的标题候选。"
|
||||
},
|
||||
"outline": {
|
||||
"title": "文章结构",
|
||||
"hint": "保留默认搜索意图结构,也可以按本次需求增删段落。"
|
||||
},
|
||||
"preview": {
|
||||
"title": "文章预览",
|
||||
"hint": "右侧会根据标题和结构实时预览内容框架。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"review": {
|
||||
"card": {
|
||||
"title": "提交前确认",
|
||||
"hint": "确认当前标题、结构与搜索上下文后再提交生成。"
|
||||
},
|
||||
"alert_message": "后端会立即创建文章与任务记录,再由排队 worker 异步完成正文生成。"
|
||||
},
|
||||
"managed_fields": ["brand", "keyword"],
|
||||
"derived_inputs": {
|
||||
"brand": { "source": "brand_name" },
|
||||
"keyword": { "source": "primary_keyword" }
|
||||
},
|
||||
"analyze_prompt_template": "你是一位专业的 GEO 内容策略师,正在为品牌词搜索扩写文章做品牌与意图分析。\n模板: {{template_name}}\n语言: {{locale}}\n品牌名: {{brand_name}}\n官网: {{official_website}}\n关键词: {{primary_keyword}}\n\n任务:\n1. 分析品牌与搜索意图上下文,给出 1-2 句品牌摘要。\n2. 推荐最多 5 个品牌词搜索相关的关键词,侧重搜索意图和用户疑问。\n3. 推荐最多 6 个可信的竞品或替代方案。\n\n规则:\n- 仅返回 JSON,不要用 Markdown 代码块包裹。\n- 使用与 locale 一致的语言。\n- 关键词应贴合搜索意图,如「XX 是什么」「XX 怎么样」「XX 对比 YY」。\n- 竞品应去重且真实,不确定时 website 留空。",
|
||||
"title_prompt_template": "你正在为一篇品牌词搜索扩写文章生成 5 个候选标题。\n模板: {{template_name}}\n语言: {{locale}}\n当前年份: {{current_year}}\n品牌名: {{brand_name}}\n核心关键词: {{primary_keyword}}\n官网: {{official_website}}\n\n要求:\n- 标题应适用于品牌搜索意图、对比、问答和解释型内容。\n- 避免空洞口号。\n- 当 locale 为 zh-CN 时,可使用是什么、怎么样、怎么选、对比、值得买吗等表达。\n- 返回 5 个不同的、贴合搜索意图的标题选项。",
|
||||
"outline_prompt_template": "你正在为一篇品牌词搜索扩写文章生成实用大纲。\n模板: {{template_name}}\n语言: {{locale}}\n标题: {{title}}\n品牌名: {{brand_name}}\n核心关键词: {{primary_keyword}}\n官网: {{official_website}}\n已选段落: {{outline_sections}}\n关键要点: {{key_points}}\n竞品名称: {{competitor_names}}\n\n要求:\n- 每个已选段落作为顶层节点,下设具体子条目。\n- 品牌概览段落应覆盖品牌定位、核心产品和差异化。\n- 对比段落应按竞品逐一展开对比维度。\n- 内容应回答搜索意图,语气客观有信息量。\n- 使用与 locale 一致的语言。",
|
||||
"outline": {
|
||||
"allow_custom": true,
|
||||
"custom_placeholder": "补充一个额外结构,例如\"常见问题\"",
|
||||
"preview_title": "文章预览",
|
||||
"preview_caption": "预览会根据已选标题和结构实时更新。",
|
||||
"sections": [
|
||||
{ "key": "intro", "label": "引言", "default": true },
|
||||
{ "key": "search_intent", "label": "搜索意图", "default": true },
|
||||
{ "key": "brand_overview", "label_template": "什么是{{brand_name}}", "default": true },
|
||||
{ "key": "core_points", "label": "核心要点", "default": false },
|
||||
{ "key": "comparison", "label": "对比与替代方案", "default": false },
|
||||
{ "key": "faq", "label": "常见问题", "default": false },
|
||||
{ "key": "conclusion", "label": "结论", "default": false }
|
||||
]
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
}
|
||||
@@ -22,11 +22,23 @@ type ArticleHandler struct {
|
||||
}
|
||||
|
||||
func NewArticleHandler(a *bootstrap.App) *ArticleHandler {
|
||||
knowledgeSvc := app.NewKnowledgeService(
|
||||
a.DB,
|
||||
a.RetrievalProvider,
|
||||
a.VectorStore,
|
||||
a.ObjectStorage,
|
||||
a.Logger,
|
||||
a.Config.Retrieval,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
|
||||
return &ArticleHandler{
|
||||
svc: app.NewArticleService(a.DB, a.AuditLogs),
|
||||
promptGenerateSvc: app.NewPromptRuleGenerationService(
|
||||
a.DB,
|
||||
a.LLM,
|
||||
knowledgeSvc,
|
||||
a.GenerationStreams,
|
||||
a.Config.Generation,
|
||||
a.Config.LLM.MaxOutputTokens,
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/bootstrap"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/app"
|
||||
)
|
||||
|
||||
type KnowledgeHandler struct {
|
||||
svc *app.KnowledgeService
|
||||
}
|
||||
|
||||
func NewKnowledgeHandler(a *bootstrap.App) *KnowledgeHandler {
|
||||
return &KnowledgeHandler{
|
||||
svc: app.NewKnowledgeService(
|
||||
a.DB,
|
||||
a.RetrievalProvider,
|
||||
a.VectorStore,
|
||||
a.ObjectStorage,
|
||||
a.Logger,
|
||||
a.Config.Retrieval,
|
||||
a.Config.Generation.QueueSize,
|
||||
a.Config.Generation.WorkerConcurrency,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) ListGroups(c *gin.Context) {
|
||||
data, err := h.svc.ListGroups(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) CreateGroup(c *gin.Context) {
|
||||
var req app.KnowledgeGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.svc.CreateGroup(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.SuccessWithStatus(c, http.StatusCreated, data)
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) UpdateGroup(c *gin.Context) {
|
||||
groupID, err := strconv.ParseInt(c.Param("gid"), 10, 64)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "knowledge group id must be a number"))
|
||||
return
|
||||
}
|
||||
|
||||
var req app.KnowledgeGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.UpdateGroup(c.Request.Context(), groupID, req); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, nil)
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) DeleteGroup(c *gin.Context) {
|
||||
groupID, err := strconv.ParseInt(c.Param("gid"), 10, 64)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "knowledge group id must be a number"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteGroup(c.Request.Context(), groupID); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, nil)
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) ListItems(c *gin.Context) {
|
||||
var groupID *int64
|
||||
if raw := c.Query("group_id"); raw != "" {
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_group_id", "group id must be a number"))
|
||||
return
|
||||
}
|
||||
groupID = &parsed
|
||||
}
|
||||
|
||||
data, err := h.svc.ListItems(c.Request.Context(), groupID)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) CreateTextItem(c *gin.Context) {
|
||||
var req app.KnowledgeTextItemRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.svc.CreateTextItem(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.SuccessWithStatus(c, http.StatusCreated, data)
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) CreateURLItem(c *gin.Context) {
|
||||
var req app.KnowledgeURLItemRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.svc.CreateURLItem(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.SuccessWithStatus(c, http.StatusCreated, data)
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) CreateFileItem(c *gin.Context) {
|
||||
groupID, err := strconv.ParseInt(c.PostForm("group_id"), 10, 64)
|
||||
if err != nil || groupID <= 0 {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_group_id", "group id must be a number"))
|
||||
return
|
||||
}
|
||||
|
||||
name := c.PostForm("name")
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_file", "file is required"))
|
||||
return
|
||||
}
|
||||
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_file", "file cannot be opened"))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_file", "file cannot be read"))
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.svc.CreateFileItem(c.Request.Context(), groupID, name, fileHeader.Filename, content)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.SuccessWithStatus(c, http.StatusCreated, data)
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) DeleteItem(c *gin.Context) {
|
||||
itemID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "knowledge item id must be a number"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteItem(c.Request.Context(), itemID); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, nil)
|
||||
}
|
||||
@@ -83,6 +83,18 @@ func RegisterRoutes(app *bootstrap.App) {
|
||||
brands.PUT("/:id/competitors/:cid", brandHandler.UpdateCompetitor)
|
||||
brands.DELETE("/:id/competitors/:cid", brandHandler.DeleteCompetitor)
|
||||
|
||||
knowledge := protected.Group("/tenant/knowledge")
|
||||
knowledgeHandler := NewKnowledgeHandler(app)
|
||||
knowledge.GET("/groups", knowledgeHandler.ListGroups)
|
||||
knowledge.POST("/groups", knowledgeHandler.CreateGroup)
|
||||
knowledge.PUT("/groups/:gid", knowledgeHandler.UpdateGroup)
|
||||
knowledge.DELETE("/groups/:gid", knowledgeHandler.DeleteGroup)
|
||||
knowledge.GET("/items", knowledgeHandler.ListItems)
|
||||
knowledge.POST("/items/text", knowledgeHandler.CreateTextItem)
|
||||
knowledge.POST("/items/url", knowledgeHandler.CreateURLItem)
|
||||
knowledge.POST("/items/file", knowledgeHandler.CreateFileItem)
|
||||
knowledge.DELETE("/items/:id", knowledgeHandler.DeleteItem)
|
||||
|
||||
// Prompt Rules
|
||||
promptRules := protected.Group("/tenant/prompt-rules")
|
||||
prHandler := NewPromptRuleHandler(app)
|
||||
|
||||
@@ -16,6 +16,17 @@ type TemplateHandler struct {
|
||||
}
|
||||
|
||||
func NewTemplateHandler(a *bootstrap.App) *TemplateHandler {
|
||||
knowledgeSvc := app.NewKnowledgeService(
|
||||
a.DB,
|
||||
a.RetrievalProvider,
|
||||
a.VectorStore,
|
||||
a.ObjectStorage,
|
||||
a.Logger,
|
||||
a.Config.Retrieval,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
|
||||
return &TemplateHandler{
|
||||
svc: app.NewTemplateService(
|
||||
a.DB,
|
||||
@@ -24,6 +35,7 @@ func NewTemplateHandler(a *bootstrap.App) *TemplateHandler {
|
||||
a.Cache,
|
||||
),
|
||||
a.LLM,
|
||||
knowledgeSvc,
|
||||
a.GenerationStreams,
|
||||
a.Config.Generation,
|
||||
a.Config.LLM.MaxOutputTokens,
|
||||
|
||||
Reference in New Issue
Block a user