feat: load question expansion prompts from yaml
This commit is contained in:
@@ -125,6 +125,42 @@ runtime:
|
||||
|
||||
JSON 输出示例:
|
||||
%s
|
||||
question_distill_prompt_template: |
|
||||
你是一个 GEO(Generative Engine Optimization)资深策略师。
|
||||
请围绕当前品牌和输入主题,生成中国用户在 AI 搜索里真实会问的问题。
|
||||
|
||||
【输入】
|
||||
- 当前品牌: %s
|
||||
- 竞品列表: %s
|
||||
- 主题: %s
|
||||
|
||||
【约束】
|
||||
1. candidates 长度不超过 20。
|
||||
2. 问题必须是自然口语问句,避免口号、短词和营销文案。
|
||||
3. 尽量覆盖 informational、evaluative、decisional 三类意图。
|
||||
4. 至少 4 条包含地域、价位、人群或场景修饰。
|
||||
5. 每条问题尽量不超过 40 个中文字符。
|
||||
6. 严格按响应格式输出,不要解释。
|
||||
question_combination_fill_prompt_template: |
|
||||
你是中国 GEO 搜索问题拓展策略师。请为“拓词工具”的 5 个词列生成高频、核心、可组合的问题词组。
|
||||
|
||||
【品牌信息】
|
||||
- 品牌: %s
|
||||
- 官网: %s
|
||||
- 描述: %s
|
||||
- 竞品: %s
|
||||
|
||||
【地域优先词】
|
||||
%s
|
||||
|
||||
【当前输入,仅作为语义参考,可改写】
|
||||
%s
|
||||
|
||||
【输出要求】
|
||||
1. region 使用地域优先词,不要编造不存在的城市;没有有效地域时可输出全国、本地、华东等泛地域词。
|
||||
2. prefix/core/industry/suffix 各输出 3 到 4 个词,必须短、热、可直接拼成搜索问题。
|
||||
3. core 要贴近品牌最核心的搜索需求;industry 要贴近行业/品类;suffix 偏“哪家好、怎么选、推荐、多少钱”等高转化问法。
|
||||
4. 不要输出完整问题,不要带标点,不要解释,严格按 JSON Schema 输出。
|
||||
title_custom_output_requirements_section: |
|
||||
输出要求:
|
||||
- 仅返回 JSON,不要用 Markdown 代码块包裹。
|
||||
|
||||
@@ -2,8 +2,9 @@ package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/prompts"
|
||||
)
|
||||
|
||||
var questionCombinationFillSchema = []byte(`{
|
||||
@@ -46,25 +47,7 @@ func buildQuestionCombinationFillPrompt(brandCtx *questionBrandContext, regionTe
|
||||
competitors = string(encoded)
|
||||
}
|
||||
hints, _ := json.Marshal(req)
|
||||
return fmt.Sprintf(`你是中国 GEO 搜索问题拓展策略师。请为“拓词工具”的 5 个词列生成高频、核心、可组合的问题词组。
|
||||
|
||||
【品牌信息】
|
||||
- 品牌: %s
|
||||
- 官网: %s
|
||||
- 描述: %s
|
||||
- 竞品: %s
|
||||
|
||||
【地域优先词】
|
||||
%s
|
||||
|
||||
【当前输入,仅作为语义参考,可改写】
|
||||
%s
|
||||
|
||||
【输出要求】
|
||||
1. region 使用地域优先词,不要编造不存在的城市;没有有效地域时可输出全国、本地、华东等泛地域词。
|
||||
2. prefix/core/industry/suffix 各输出 3 到 4 个词,必须短、热、可直接拼成搜索问题。
|
||||
3. core 要贴近品牌最核心的搜索需求;industry 要贴近行业/品类;suffix 偏“哪家好、怎么选、推荐、多少钱”等高转化问法。
|
||||
4. 不要输出完整问题,不要带标点,不要解释,严格按 JSON Schema 输出。`,
|
||||
return prompts.QuestionCombinationFillPrompt(
|
||||
strings.TrimSpace(brandCtx.BrandName),
|
||||
strings.TrimSpace(nilToString(brandCtx.Website)),
|
||||
strings.TrimSpace(nilToString(brandCtx.Description)),
|
||||
|
||||
@@ -2,8 +2,9 @@ package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/prompts"
|
||||
)
|
||||
|
||||
var questionDistillSchema = []byte(`{
|
||||
@@ -38,29 +39,13 @@ var questionDistillSchema = []byte(`{
|
||||
"required": ["candidates"]
|
||||
}`)
|
||||
|
||||
const questionDistillPromptVersion = "question_distill_v1"
|
||||
|
||||
func buildQuestionDistillPrompt(brandName string, competitorNames []string, seedTopic string) string {
|
||||
competitors := "无"
|
||||
if len(competitorNames) > 0 {
|
||||
encoded, _ := json.Marshal(competitorNames)
|
||||
competitors = string(encoded)
|
||||
}
|
||||
return fmt.Sprintf(`你是一个 GEO(Generative Engine Optimization)资深策略师。
|
||||
请围绕当前品牌和输入主题,生成中国用户在 AI 搜索里真实会问的问题。
|
||||
|
||||
【输入】
|
||||
- 当前品牌: %s
|
||||
- 竞品列表: %s
|
||||
- 主题: %s
|
||||
|
||||
【约束】
|
||||
1. candidates 长度不超过 20。
|
||||
2. 问题必须是自然口语问句,避免口号、短词和营销文案。
|
||||
3. 尽量覆盖 informational、evaluative、decisional 三类意图。
|
||||
4. 至少 4 条包含地域、价位、人群或场景修饰。
|
||||
5. 每条问题尽量不超过 40 个中文字符。
|
||||
6. 严格按响应格式输出,不要解释。`,
|
||||
return prompts.QuestionDistillPrompt(
|
||||
strings.TrimSpace(brandName),
|
||||
competitors,
|
||||
strings.TrimSpace(seedTopic),
|
||||
|
||||
@@ -236,7 +236,9 @@ func (s *QuestionExpansionService) GenerateByAIDistill(ctx context.Context, bran
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cacheKey := questionDistillCacheKey(actor.TenantID, brandID, questionDistillPromptVersion, brandCtx.BrandName, brandCtx.CompetitorNames, seedTopic)
|
||||
prompt := buildQuestionDistillPrompt(brandCtx.BrandName, brandCtx.CompetitorNames, seedTopic)
|
||||
promptVersion := questionPromptHash(prompt)
|
||||
cacheKey := questionDistillCacheKey(actor.TenantID, brandID, promptVersion, brandCtx.BrandName, brandCtx.CompetitorNames, seedTopic)
|
||||
if s.cache != nil {
|
||||
if raw, cacheErr := s.cache.Get(ctx, cacheKey); cacheErr == nil && len(raw) > 0 {
|
||||
var cached []QuestionCandidate
|
||||
@@ -266,14 +268,13 @@ func (s *QuestionExpansionService) GenerateByAIDistill(ctx context.Context, bran
|
||||
Metadata: map[string]any{
|
||||
"brand_id": brandID,
|
||||
"seed_topic": seedTopic,
|
||||
"prompt_version": questionDistillPromptVersion,
|
||||
"prompt_version": promptVersion,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prompt := buildQuestionDistillPrompt(brandCtx.BrandName, brandCtx.CompetitorNames, seedTopic)
|
||||
result, err := s.llm.Generate(ctx, llm.GenerateRequest{
|
||||
Prompt: prompt,
|
||||
Timeout: questionDistillTimeout,
|
||||
@@ -346,8 +347,10 @@ func (s *QuestionExpansionService) FillQuestionCombination(ctx context.Context,
|
||||
regionTerms = questionCombinationRegionTermsFromIPRegion(s.ipGeo.Lookup(clientIP))
|
||||
}
|
||||
fallback := buildQuestionCombinationFillFallback(brandCtx, regionTerms, req)
|
||||
prompt := buildQuestionCombinationFillPrompt(brandCtx, fallback.Region, req)
|
||||
promptVersion := questionPromptHash(prompt)
|
||||
|
||||
cacheKey := questionCombinationFillCacheKey(actor.TenantID, brandID, brandCtx, regionTerms, req)
|
||||
cacheKey := questionCombinationFillCacheKey(actor.TenantID, brandID, promptVersion, brandCtx, regionTerms, req)
|
||||
if s.cache != nil {
|
||||
if raw, cacheErr := s.cache.Get(ctx, cacheKey); cacheErr == nil && len(raw) > 0 {
|
||||
var cached QuestionCombinationFillResult
|
||||
@@ -381,7 +384,7 @@ func (s *QuestionExpansionService) FillQuestionCombination(ctx context.Context,
|
||||
FixedPoints: 1,
|
||||
Metadata: map[string]any{
|
||||
"brand_id": brandID,
|
||||
"prompt_version": "question_combination_fill_v1",
|
||||
"prompt_version": promptVersion,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -389,7 +392,7 @@ func (s *QuestionExpansionService) FillQuestionCombination(ctx context.Context,
|
||||
}
|
||||
|
||||
result, err := s.llm.Generate(ctx, llm.GenerateRequest{
|
||||
Prompt: buildQuestionCombinationFillPrompt(brandCtx, fallback.Region, req),
|
||||
Prompt: prompt,
|
||||
Timeout: questionCombinationFillTimeout,
|
||||
MaxOutputTokens: 900,
|
||||
ResponseFormat: &llm.ResponseFormat{
|
||||
@@ -1051,10 +1054,11 @@ func inferQuestionCombinationCategory(brandName, description string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func questionCombinationFillCacheKey(tenantID, brandID int64, brandCtx *questionBrandContext, regionTerms []string, req QuestionCombinationFillRequest) string {
|
||||
func questionCombinationFillCacheKey(tenantID, brandID int64, promptVersion string, brandCtx *questionBrandContext, regionTerms []string, req QuestionCombinationFillRequest) string {
|
||||
raw, _ := json.Marshal(struct {
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
BrandID int64 `json:"brand_id"`
|
||||
PromptVersion string `json:"prompt_version"`
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
BrandName string `json:"brand_name"`
|
||||
Description string `json:"description"`
|
||||
@@ -1064,6 +1068,7 @@ func questionCombinationFillCacheKey(tenantID, brandID int64, brandCtx *question
|
||||
}{
|
||||
TenantID: tenantID,
|
||||
BrandID: brandID,
|
||||
PromptVersion: promptVersion,
|
||||
SchemaVersion: "question_combination_fill_v1",
|
||||
BrandName: strings.TrimSpace(brandCtx.BrandName),
|
||||
Description: strings.TrimSpace(nilToString(brandCtx.Description)),
|
||||
@@ -1081,6 +1086,11 @@ func questionCombinationFillCacheKey(tenantID, brandID int64, brandCtx *question
|
||||
return "question_combination_fill:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func questionPromptHash(prompt string) string {
|
||||
sum := sha1.Sum([]byte(strings.TrimSpace(prompt)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func nilToString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
|
||||
@@ -34,6 +34,8 @@ type runtimePromptsConfig struct {
|
||||
JSONOutputExampleHeading string `yaml:"json_output_example_heading"`
|
||||
AnalyzeOutputExample string `yaml:"analyze_output_example"`
|
||||
AnalyzeFallbackPromptTemplate string `yaml:"analyze_fallback_prompt_template"`
|
||||
QuestionDistillPromptTemplate string `yaml:"question_distill_prompt_template"`
|
||||
QuestionCombinationFillPromptTemplate string `yaml:"question_combination_fill_prompt_template"`
|
||||
TitleCustomOutputRequirementsSection string `yaml:"title_custom_output_requirements_section"`
|
||||
TitleOutputExample string `yaml:"title_output_example"`
|
||||
TitleFallbackPromptTemplate string `yaml:"title_fallback_prompt_template"`
|
||||
|
||||
@@ -66,16 +66,48 @@ func TestAnalyzeFallbackPromptPrefersQuestionLikeQueries(t *testing.T) {
|
||||
|
||||
prompt := AnalyzeFallbackPrompt(`{"brand_name":"测试品牌","input_params":{"keyword":"全屋定制"}}`)
|
||||
for _, want := range []string{
|
||||
"真实用户会直接搜索的问题型长尾词",
|
||||
"哪家好",
|
||||
"最多返回 6 个竞品和 10 个搜索问题候选",
|
||||
} {
|
||||
"真实用户会直接搜索的问题型长尾词",
|
||||
"哪家好",
|
||||
"最多返回 6 个竞品和 10 个搜索问题候选",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("AnalyzeFallbackPrompt() missing %q in prompt:\n%s", want, prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuestionExpansionPromptsLoadFromYaml(t *testing.T) {
|
||||
SetConfigFile("")
|
||||
t.Cleanup(func() {
|
||||
SetConfigFile("")
|
||||
})
|
||||
|
||||
distillPrompt := QuestionDistillPrompt("测试品牌", `["竞品A"]`, "全屋定制")
|
||||
for _, want := range []string{
|
||||
"AI 搜索里真实会问的问题",
|
||||
"当前品牌: 测试品牌",
|
||||
`竞品列表: ["竞品A"]`,
|
||||
"主题: 全屋定制",
|
||||
} {
|
||||
if !strings.Contains(distillPrompt, want) {
|
||||
t.Fatalf("QuestionDistillPrompt() missing %q in prompt:\n%s", want, distillPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
fillPrompt := QuestionCombinationFillPrompt("测试品牌", "https://example.com", "品牌描述", `["竞品A"]`, "合肥、华东", `{"core":["装修"]}`)
|
||||
for _, want := range []string{
|
||||
"拓词工具",
|
||||
"品牌: 测试品牌",
|
||||
"官网: https://example.com",
|
||||
"合肥、华东",
|
||||
`{"core":["装修"]}`,
|
||||
} {
|
||||
if !strings.Contains(fillPrompt, want) {
|
||||
t.Fatalf("QuestionCombinationFillPrompt() missing %q in prompt:\n%s", want, fillPrompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptConfigReloadsAndKeepsLastGoodVersion(t *testing.T) {
|
||||
SetConfigFile("")
|
||||
defaultPath := resolvePromptsConfigFile()
|
||||
|
||||
@@ -69,6 +69,27 @@ func AnalyzeFallbackPrompt(contextJSON string) string {
|
||||
))
|
||||
}
|
||||
|
||||
func QuestionDistillPrompt(brandName, competitors, seedTopic string) string {
|
||||
return strings.TrimSpace(fmt.Sprintf(
|
||||
trimPrompt(loadRuntimePrompts().QuestionDistillPromptTemplate),
|
||||
strings.TrimSpace(brandName),
|
||||
strings.TrimSpace(competitors),
|
||||
strings.TrimSpace(seedTopic),
|
||||
))
|
||||
}
|
||||
|
||||
func QuestionCombinationFillPrompt(brandName, website, description, competitors, regionTerms, currentInput string) string {
|
||||
return strings.TrimSpace(fmt.Sprintf(
|
||||
trimPrompt(loadRuntimePrompts().QuestionCombinationFillPromptTemplate),
|
||||
strings.TrimSpace(brandName),
|
||||
strings.TrimSpace(website),
|
||||
strings.TrimSpace(description),
|
||||
strings.TrimSpace(competitors),
|
||||
strings.TrimSpace(regionTerms),
|
||||
strings.TrimSpace(currentInput),
|
||||
))
|
||||
}
|
||||
|
||||
func TitleCustomOutputRequirementsSection() string {
|
||||
return trimPrompt(loadRuntimePrompts().TitleCustomOutputRequirementsSection)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user