7fa1809682
- Added support for external markdown synchronization in ArticleEditorCanvas.vue. - Implemented a new function to sync markdown from props, ensuring the editor reflects external changes. - Introduced a removeOutlineSection function in TemplateWizardView.vue to manage outline sections dynamically. - Updated UI components to improve user experience, including replacing a-input with a-textarea for better text handling. - Refactored CSS styles for a cleaner layout and improved responsiveness in TemplateWizardView.vue. - Enhanced LLM generation requests to include response format options in ark.go and client.go. - Updated template assist logic to enforce structured outline outputs in template_assist.go. - Added tests for outline result decoding and prompt generation to ensure robustness. - Created migration scripts to update prompt templates with new writing requirements for top X articles.
522 lines
14 KiB
Go
522 lines
14 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(templateKey, 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 templateRules := buildTemplateSpecificWritingRules(templateKey, params); templateRules != "" {
|
|
sections = append(sections, "模板专项要求:\n"+templateRules)
|
|
}
|
|
|
|
if lengthGuidance := buildGenerationLengthGuidance(params); lengthGuidance != "" {
|
|
sections = append(sections, "篇幅要求:\n"+lengthGuidance)
|
|
}
|
|
|
|
return strings.Join(sections, "\n\n")
|
|
}
|
|
|
|
func buildTemplateSpecificWritingRules(templateKey string, params map[string]interface{}) string {
|
|
switch strings.TrimSpace(templateKey) {
|
|
case "top_x_article":
|
|
brandName := strings.TrimSpace(stringValue(params["brand_name"]))
|
|
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")
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
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 个英文单词,优先信息密度,不要为了拉长篇幅重复表达。", minWords, maxWords),
|
|
"- 引言和结论保持简洁,大多数一级章节写 1-3 个紧凑段落即可;只有最重要的章节需要展开。",
|
|
"- 一段能说清的内容不要拆成多段重复表达。",
|
|
}, "\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", promptContextLabel(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 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
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|