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.
This commit is contained in:
@@ -5,7 +5,8 @@ import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/contentstats"
|
||||
)
|
||||
|
||||
var promptVariablePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_]+)\s*\}\}`)
|
||||
@@ -13,7 +14,7 @@ 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("Write a complete article in markdown for the template %q.", templateName)
|
||||
basePrompt = fmt.Sprintf("你是一名专业内容编辑,请基于已提供的信息,为模板「%s」写出一篇完整的 Markdown 文章。", templateName)
|
||||
}
|
||||
|
||||
var sections []string
|
||||
@@ -21,20 +22,108 @@ func buildGenerationPrompt(templateName string, promptTemplate *string, params m
|
||||
|
||||
contextBlock := buildPromptContext(params)
|
||||
if contextBlock != "" {
|
||||
sections = append(sections, "Request context:\n"+contextBlock)
|
||||
sections = append(sections, "当前上下文:\n"+contextBlock)
|
||||
}
|
||||
|
||||
sections = append(sections, strings.Join([]string{
|
||||
"Output requirements:",
|
||||
"- Return only article markdown, no surrounding explanation.",
|
||||
"- Use the provided title when available.",
|
||||
"- Follow the provided outline sections and key points when available.",
|
||||
"- Keep structure clear with headings, short paragraphs, and concrete details.",
|
||||
"写作总要求:",
|
||||
"- 仅返回文章 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 ""
|
||||
@@ -66,12 +155,15 @@ func buildPromptContext(params map[string]interface{}) string {
|
||||
"product_name",
|
||||
"subject",
|
||||
"brand_name",
|
||||
"primary_keyword",
|
||||
"brand",
|
||||
"category",
|
||||
"count",
|
||||
"depth",
|
||||
"article_outline",
|
||||
"outline_sections",
|
||||
"key_points",
|
||||
"review_intro_hook",
|
||||
"keywords",
|
||||
"competitors",
|
||||
}
|
||||
@@ -83,7 +175,10 @@ func buildPromptContext(params map[string]interface{}) string {
|
||||
if value == nil {
|
||||
return
|
||||
}
|
||||
formatted := strings.TrimSpace(formatPromptValue(value))
|
||||
if key == "outline_sections" && hasStructuredOutline(params["article_outline"]) {
|
||||
return
|
||||
}
|
||||
formatted := strings.TrimSpace(formatPromptContextValue(key, value))
|
||||
if formatted == "" {
|
||||
return
|
||||
}
|
||||
@@ -105,6 +200,21 @@ func buildPromptContext(params map[string]interface{}) string {
|
||||
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:
|
||||
@@ -114,6 +224,12 @@ func formatPromptValue(value interface{}) string {
|
||||
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))
|
||||
@@ -131,6 +247,156 @@ func formatPromptValue(value interface{}) string {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -155,17 +421,7 @@ func resolveArticleTitle(params map[string]interface{}, markdown string) string
|
||||
}
|
||||
|
||||
func estimateWordCount(markdown string) int {
|
||||
trimmed := strings.TrimSpace(markdown)
|
||||
if trimmed == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
words := len(strings.Fields(trimmed))
|
||||
if words > 0 {
|
||||
return words
|
||||
}
|
||||
|
||||
return utf8.RuneCountInString(trimmed)
|
||||
return contentstats.CountWords(markdown)
|
||||
}
|
||||
|
||||
func stringValue(value interface{}) string {
|
||||
|
||||
Reference in New Issue
Block a user