diff --git a/deploy/k3s/config/prompts.yml b/deploy/k3s/config/prompts.yml index 7fe0b50..73e6dd6 100644 --- a/deploy/k3s/config/prompts.yml +++ b/deploy/k3s/config/prompts.yml @@ -120,6 +120,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 代码块包裹。 diff --git a/deploy/prompts.yml b/deploy/prompts.yml index 7fe0b50..73e6dd6 100644 --- a/deploy/prompts.yml +++ b/deploy/prompts.yml @@ -120,6 +120,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 代码块包裹。 diff --git a/server/configs/prompts.yml b/server/configs/prompts.yml index 5fb0f22..905ccbe 100644 --- a/server/configs/prompts.yml +++ b/server/configs/prompts.yml @@ -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 代码块包裹。 diff --git a/server/internal/tenant/app/question_combination_fill_prompt.go b/server/internal/tenant/app/question_combination_fill_prompt.go index 72dad8f..da10875 100644 --- a/server/internal/tenant/app/question_combination_fill_prompt.go +++ b/server/internal/tenant/app/question_combination_fill_prompt.go @@ -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)), diff --git a/server/internal/tenant/app/question_distill_prompt.go b/server/internal/tenant/app/question_distill_prompt.go index b296ff4..425ee46 100644 --- a/server/internal/tenant/app/question_distill_prompt.go +++ b/server/internal/tenant/app/question_distill_prompt.go @@ -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), diff --git a/server/internal/tenant/app/question_expansion_service.go b/server/internal/tenant/app/question_expansion_service.go index 8f60683..22eddfc 100644 --- a/server/internal/tenant/app/question_expansion_service.go +++ b/server/internal/tenant/app/question_expansion_service.go @@ -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 "" diff --git a/server/internal/tenant/prompts/loader.go b/server/internal/tenant/prompts/loader.go index de050b3..1a12454 100644 --- a/server/internal/tenant/prompts/loader.go +++ b/server/internal/tenant/prompts/loader.go @@ -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"` diff --git a/server/internal/tenant/prompts/loader_test.go b/server/internal/tenant/prompts/loader_test.go index 0a2d2f7..e89e6d3 100644 --- a/server/internal/tenant/prompts/loader_test.go +++ b/server/internal/tenant/prompts/loader_test.go @@ -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() diff --git a/server/internal/tenant/prompts/runtime.go b/server/internal/tenant/prompts/runtime.go index ca6e2d9..d3e7a40 100644 --- a/server/internal/tenant/prompts/runtime.go +++ b/server/internal/tenant/prompts/runtime.go @@ -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) }