Files
geo/server/internal/tenant/app/template_prompt.go
T
root b31d8d0096 feat(migrations): Harden task audit tracking and optimize article templates
- Added migration to harden task audit tracking by modifying audit_logs and related tables.
- Introduced operator_id to several tables for better tracking of actions.
- Updated article_templates with new prompt templates for various article types, enhancing content generation.
- Created prompt_rules and schedule_tasks tables to manage content generation rules and scheduling.
- Added foreign key constraints to articles for better data integrity.
2026-04-02 00:31:28 +08:00

437 lines
11 KiB
Go

package app
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"github.com/geo-platform/tenant-api/internal/shared/contentstats"
)
var promptVariablePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_]+)\s*\}\}`)
func buildGenerationPrompt(templateName string, promptTemplate *string, params map[string]interface{}) string {
basePrompt := strings.TrimSpace(renderPromptTemplate(promptTemplate, params))
if basePrompt == "" {
basePrompt = fmt.Sprintf("你是一名专业内容编辑,请基于已提供的信息,为模板「%s」写出一篇完整的 Markdown 文章。", templateName)
}
var sections []string
sections = append(sections, basePrompt)
contextBlock := buildPromptContext(params)
if contextBlock != "" {
sections = append(sections, "当前上下文:\n"+contextBlock)
}
sections = append(sections, strings.Join([]string{
"写作总要求:",
"- 仅返回文章 Markdown 正文,不要附带额外说明、提示语或代码块。",
"- 输出语言与 locale 一致:zh-CN 使用简体中文,en-US 使用自然、专业的英语。",
"- 如提供了 title,使用该标题作为文章主标题,并围绕它展开,不要另起一个无关标题。",
"- 如提供了 article_outline,一级节点是正文的最终小节标题,必须按顺序展开;二级及更深节点仅作为该小节的行文思路、论证顺序或信息要点,不要机械写成额外标题。",
"- 除主标题和一级小节标题外,默认不要把大纲子节点直接写成 Markdown 标题;子节点内容应自然融入段落、列表或过渡句中。",
"- 如提供了 key_points,正文中必须覆盖这些重点,不要遗漏。",
"- 每个核心段落都要有明确判断、原因解释、适用场景或对比维度,避免空话、套话和重复表述。",
"- 信息不足时,不要编造具体事实、价格、数据、测试结果、用户评价或机构结论;可以用稳妥表述说明判断边界。",
"- 保持客观、克制、非广告化,不使用夸张宣传语,如“顶级”“颠覆性”“完美”等。",
"- 使用清晰的小节标题和短段落;仅在确实有助于理解时使用列表。",
"- 结尾应给出清晰结论、适合对象、不适合对象,或下一步建议,帮助读者完成判断。",
}, "\n"))
if lengthGuidance := buildGenerationLengthGuidance(params); lengthGuidance != "" {
sections = append(sections, "篇幅要求:\n"+lengthGuidance)
}
return strings.Join(sections, "\n\n")
}
func buildGenerationLengthGuidance(params map[string]interface{}) string {
sectionCount := estimateTopLevelSectionCount(params)
if sectionCount <= 0 {
sectionCount = 4
}
locale := strings.TrimSpace(stringValue(params["locale"]))
if locale == "en-US" {
minWords := 900
targetWords := 320 + sectionCount*130
if targetWords > minWords {
minWords = targetWords
}
if minWords > 1600 {
minWords = 1600
}
maxWords := minWords + 400
if maxWords > 2100 {
maxWords = 2100
}
return strings.Join([]string{
fmt.Sprintf("- 建议全文控制在 %d-%d English words,优先信息密度,不要为了拉长篇幅重复表达。", minWords, maxWords),
"- Intro and conclusion should stay concise. Most top-level sections only need 1-3 tight paragraphs; expand only the most important sections.",
"- If one paragraph can make the point clearly, do not split it into multiple repetitive paragraphs.",
}, "\n")
}
minChars := 1100
targetChars := 320 + sectionCount*180
if targetChars > minChars {
minChars = targetChars
}
if minChars > 1800 {
minChars = 1800
}
maxChars := minChars + 500
if maxChars > 2400 {
maxChars = 2400
}
return strings.Join([]string{
fmt.Sprintf("- 建议全文控制在 %d-%d 字左右,优先信息密度,不要为了凑字数重复表达。", minChars, maxChars),
"- 引言和结论保持简洁,大多数一级章节写 1-3 个自然段即可;只有最关键的章节需要更充分展开。",
"- 能用一段说清的内容,不要拆成多段同义反复;主体部分重点写判断、原因、场景和建议。",
}, "\n")
}
func estimateTopLevelSectionCount(params map[string]interface{}) int {
if params == nil {
return 0
}
switch value := params["article_outline"].(type) {
case []interface{}:
if len(value) > 0 {
return len(value)
}
case []map[string]interface{}:
if len(value) > 0 {
return len(value)
}
}
switch value := params["outline_sections"].(type) {
case []interface{}:
if len(value) > 0 {
return len(value)
}
case []string:
if len(value) > 0 {
return len(value)
}
}
return 0
}
func renderPromptTemplate(promptTemplate *string, params map[string]interface{}) string {
if promptTemplate == nil {
return ""
}
return promptVariablePattern.ReplaceAllStringFunc(*promptTemplate, func(match string) string {
submatches := promptVariablePattern.FindStringSubmatch(match)
if len(submatches) != 2 {
return match
}
value, ok := params[submatches[1]]
if !ok {
return ""
}
return formatPromptValue(value)
})
}
func buildPromptContext(params map[string]interface{}) string {
if len(params) == 0 {
return ""
}
orderedKeys := []string{
"locale",
"title",
"topic",
"product_name",
"subject",
"brand_name",
"primary_keyword",
"brand",
"category",
"count",
"depth",
"article_outline",
"outline_sections",
"key_points",
"review_intro_hook",
"keywords",
"competitors",
}
used := make(map[string]struct{}, len(params))
lines := make([]string, 0, len(params))
appendLine := func(key string, value interface{}) {
if value == nil {
return
}
if key == "outline_sections" && hasStructuredOutline(params["article_outline"]) {
return
}
formatted := strings.TrimSpace(formatPromptContextValue(key, value))
if formatted == "" {
return
}
lines = append(lines, fmt.Sprintf("- %s: %s", key, formatted))
used[key] = struct{}{}
}
for _, key := range orderedKeys {
appendLine(key, params[key])
}
for key, value := range params {
if _, ok := used[key]; ok {
continue
}
appendLine(key, value)
}
return strings.Join(lines, "\n")
}
func formatPromptContextValue(key string, value interface{}) string {
switch key {
case "keywords":
return formatKeywordList(value, 6)
case "competitors":
return formatCompetitorList(value, 6)
case "outline_sections":
return formatSectionList(value, 10)
case "article_outline":
return formatOutlineValue(value)
default:
return formatPromptValue(value)
}
}
func formatPromptValue(value interface{}) string {
switch v := value.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(v)
case []string:
return strings.Join(v, ", ")
case []interface{}:
if hasOutlineItems(v) {
return formatOutlineItems(v, 0)
}
if hasNamedItems(v) {
return formatNamedItems(v, 6)
}
parts := make([]string, 0, len(v))
for _, item := range v {
formatted := strings.TrimSpace(formatPromptValue(item))
if formatted != "" {
parts = append(parts, formatted)
}
}
return strings.Join(parts, ", ")
default:
bytes, err := json.Marshal(v)
if err != nil {
return fmt.Sprint(v)
}
return string(bytes)
}
}
func formatKeywordList(value interface{}, limit int) string {
items := extractStringList(value, limit)
return strings.Join(items, ", ")
}
func formatSectionList(value interface{}, limit int) string {
items := extractStringList(value, limit)
return strings.Join(items, " > ")
}
func formatCompetitorList(value interface{}, limit int) string {
switch items := value.(type) {
case []interface{}:
names := make([]string, 0, len(items))
for _, item := range items {
if data, ok := item.(map[string]interface{}); ok {
name := strings.TrimSpace(stringValue(data["name"]))
if name == "" {
name = strings.TrimSpace(stringValue(data["brand_name"]))
}
if name != "" {
names = append(names, name)
}
}
if len(names) >= limit {
break
}
}
return strings.Join(names, ", ")
default:
return formatPromptValue(value)
}
}
func formatOutlineValue(value interface{}) string {
switch items := value.(type) {
case []interface{}:
return formatOutlineItems(items, 0)
default:
return formatPromptValue(value)
}
}
func hasStructuredOutline(value interface{}) bool {
switch items := value.(type) {
case []interface{}:
return hasOutlineItems(items)
default:
return false
}
}
func hasOutlineItems(items []interface{}) bool {
for _, item := range items {
if data, ok := item.(map[string]interface{}); ok {
if strings.TrimSpace(stringValue(data["outline"])) != "" {
return true
}
}
}
return false
}
func hasNamedItems(items []interface{}) bool {
for _, item := range items {
if data, ok := item.(map[string]interface{}); ok {
if strings.TrimSpace(stringValue(data["name"])) != "" || strings.TrimSpace(stringValue(data["brand_name"])) != "" {
return true
}
}
}
return false
}
func formatNamedItems(items []interface{}, limit int) string {
names := make([]string, 0, len(items))
for _, item := range items {
data, ok := item.(map[string]interface{})
if !ok {
continue
}
name := strings.TrimSpace(stringValue(data["name"]))
if name == "" {
name = strings.TrimSpace(stringValue(data["brand_name"]))
}
if name == "" {
continue
}
names = append(names, name)
if len(names) >= limit {
break
}
}
return strings.Join(names, ", ")
}
func formatOutlineItems(items []interface{}, level int) string {
lines := make([]string, 0)
indent := strings.Repeat(" ", level)
for _, item := range items {
node, ok := item.(map[string]interface{})
if !ok {
continue
}
outline := strings.TrimSpace(stringValue(node["outline"]))
if outline == "" {
continue
}
lines = append(lines, fmt.Sprintf("%s- %s", indent, outline))
children, ok := node["children"].([]interface{})
if ok && len(children) > 0 {
childText := formatOutlineItems(children, level+1)
if childText != "" {
lines = append(lines, childText)
}
}
}
return strings.Join(lines, "\n")
}
func extractStringList(value interface{}, limit int) []string {
items := make([]string, 0)
appendItem := func(text string) {
text = strings.TrimSpace(text)
if text == "" {
return
}
items = append(items, text)
}
switch list := value.(type) {
case []string:
for _, item := range list {
appendItem(item)
if limit > 0 && len(items) >= limit {
break
}
}
case []interface{}:
for _, item := range list {
appendItem(formatPromptValue(item))
if limit > 0 && len(items) >= limit {
break
}
}
}
return items
}
func resolveArticleTitle(params map[string]interface{}, markdown string) string {
if title := strings.TrimSpace(stringValue(params["title"])); title != "" {
return title
}
if topic := strings.TrimSpace(stringValue(params["topic"])); topic != "" {
return topic
}
if product := strings.TrimSpace(stringValue(params["product_name"])); product != "" {
return product + " Review"
}
if subject := strings.TrimSpace(stringValue(params["subject"])); subject != "" {
return subject + " Report"
}
for _, line := range strings.Split(markdown, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#") {
return strings.TrimSpace(strings.TrimLeft(line, "#"))
}
}
return "Generated Article"
}
func estimateWordCount(markdown string) int {
return contentstats.CountWords(markdown)
}
func stringValue(value interface{}) string {
if value == nil {
return ""
}
text, ok := value.(string)
if !ok {
return ""
}
return text
}