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 }