feat(questions): make brand questions the primary monitoring & template axis
- add /questions/combination-fill endpoint with AI-driven, IP-region-aware matrix fill - extract ip2region resolver from ops/app into shared/ipregion for cross-service use - thread question_id filter through dashboard composite, citation summary, and collect-now - switch template wizard from keyword inputs to brand-question selection (primary + supplemental) - pass brand_question and supplemental_questions through assist/title/outline prompts - add AiWaitingModal + 45s client timeout and request_timeout error mapping for long AI flows Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,16 +17,20 @@ import (
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/ipregion"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/llm"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/middleware"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
const (
|
||||
questionCombinationMaxItems = 500
|
||||
questionAIDistillMaxItems = 20
|
||||
questionDistillCacheTTL = 5 * time.Minute
|
||||
questionDistillTimeout = 30 * time.Second
|
||||
questionCombinationMaxItems = 500
|
||||
questionCombinationFillMax = 4
|
||||
questionAIDistillMaxItems = 20
|
||||
questionCombinationFillTTL = 10 * time.Minute
|
||||
questionCombinationFillTimeout = 15 * time.Second
|
||||
questionDistillCacheTTL = 5 * time.Minute
|
||||
questionDistillTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
type QuestionExpansionService struct {
|
||||
@@ -34,6 +38,7 @@ type QuestionExpansionService struct {
|
||||
llm llm.Client
|
||||
brand *BrandService
|
||||
cache sharedcache.Cache
|
||||
ipGeo *ipregion.Resolver
|
||||
}
|
||||
|
||||
func NewQuestionExpansionService(pool *pgxpool.Pool, llmClient llm.Client, brand *BrandService) *QuestionExpansionService {
|
||||
@@ -49,6 +54,11 @@ func (s *QuestionExpansionService) WithCache(c sharedcache.Cache) *QuestionExpan
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *QuestionExpansionService) WithIPRegionResolver(resolver *ipregion.Resolver) *QuestionExpansionService {
|
||||
s.ipGeo = resolver
|
||||
return s
|
||||
}
|
||||
|
||||
type QuestionCombinationRequest struct {
|
||||
Region []string `json:"region"`
|
||||
Prefix []string `json:"prefix"`
|
||||
@@ -62,6 +72,24 @@ type QuestionDistillRequest struct {
|
||||
SeedTopic string `json:"seed_topic" binding:"required,min=2"`
|
||||
}
|
||||
|
||||
type QuestionCombinationFillRequest struct {
|
||||
Region []string `json:"region"`
|
||||
Prefix []string `json:"prefix"`
|
||||
Core []string `json:"core"`
|
||||
Industry []string `json:"industry"`
|
||||
Suffix []string `json:"suffix"`
|
||||
}
|
||||
|
||||
type QuestionCombinationFillResult struct {
|
||||
Region []string `json:"region"`
|
||||
Prefix []string `json:"prefix"`
|
||||
Core []string `json:"core"`
|
||||
Industry []string `json:"industry"`
|
||||
Suffix []string `json:"suffix"`
|
||||
AIPointsCharged int `json:"ai_points_charged,omitempty"`
|
||||
CacheHit bool `json:"cache_hit,omitempty"`
|
||||
}
|
||||
|
||||
type QuestionCandidate struct {
|
||||
Text string `json:"text"`
|
||||
Layer string `json:"layer"`
|
||||
@@ -103,6 +131,8 @@ type questionBrandContext struct {
|
||||
TenantID int64
|
||||
BrandID int64
|
||||
BrandName string
|
||||
Website *string
|
||||
Description *string
|
||||
PlanCode string
|
||||
CompetitorNames []string
|
||||
}
|
||||
@@ -115,6 +145,14 @@ type aiDistillPayload struct {
|
||||
} `json:"candidates"`
|
||||
}
|
||||
|
||||
type aiCombinationFillPayload struct {
|
||||
Region []string `json:"region"`
|
||||
Prefix []string `json:"prefix"`
|
||||
Core []string `json:"core"`
|
||||
Industry []string `json:"industry"`
|
||||
Suffix []string `json:"suffix"`
|
||||
}
|
||||
|
||||
func (s *QuestionExpansionService) GenerateByCombination(ctx context.Context, brandID int64, req QuestionCombinationRequest) (*QuestionCandidateResult, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
brandCtx, err := s.loadQuestionBrandContext(ctx, actor.TenantID, brandID)
|
||||
@@ -250,7 +288,7 @@ func (s *QuestionExpansionService) GenerateByAIDistill(ctx context.Context, bran
|
||||
}, nil)
|
||||
if err != nil {
|
||||
_ = RefundAIPoints(context.Background(), s.pool, s.cache, actor.TenantID, actor.UserID, *reservation, err.Error())
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return nil, response.ErrServiceUnavailable(50312, "llm_timeout", "AI generation timed out")
|
||||
}
|
||||
return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error())
|
||||
@@ -296,6 +334,99 @@ func (s *QuestionExpansionService) GenerateByAIDistill(ctx context.Context, bran
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *QuestionExpansionService) FillQuestionCombination(ctx context.Context, brandID int64, clientIP string, req QuestionCombinationFillRequest) (*QuestionCombinationFillResult, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
brandCtx, err := s.loadQuestionBrandContext(ctx, actor.TenantID, brandID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
regionTerms := questionCombinationRegionTermsFromIPRegion("")
|
||||
if s.ipGeo != nil {
|
||||
regionTerms = questionCombinationRegionTermsFromIPRegion(s.ipGeo.Lookup(clientIP))
|
||||
}
|
||||
fallback := buildQuestionCombinationFillFallback(brandCtx, regionTerms, req)
|
||||
|
||||
cacheKey := questionCombinationFillCacheKey(actor.TenantID, brandID, brandCtx, regionTerms, req)
|
||||
if s.cache != nil {
|
||||
if raw, cacheErr := s.cache.Get(ctx, cacheKey); cacheErr == nil && len(raw) > 0 {
|
||||
var cached QuestionCombinationFillResult
|
||||
if json.Unmarshal(raw, &cached) == nil {
|
||||
cached.CacheHit = true
|
||||
cached.AIPointsCharged = 0
|
||||
return &cached, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if s.llm == nil || s.llm.Validate() != nil {
|
||||
return &fallback, nil
|
||||
}
|
||||
|
||||
resourceType := "question_combination_fill"
|
||||
resourceUID := cacheKey
|
||||
meteredText, _ := json.Marshal(map[string]any{
|
||||
"brand": brandCtx.BrandName,
|
||||
"description": nilToString(brandCtx.Description),
|
||||
"region": fallback.Region,
|
||||
"request": req,
|
||||
})
|
||||
reservation, err := ReserveAIPoints(ctx, s.pool, s.cache, AIPointReserveInput{
|
||||
TenantID: actor.TenantID,
|
||||
OperatorID: actor.UserID,
|
||||
UsageType: AIUsageTypeQuestionCombinationFill,
|
||||
ResourceType: &resourceType,
|
||||
ResourceUID: &resourceUID,
|
||||
MeteredText: string(meteredText),
|
||||
FixedPoints: 1,
|
||||
Metadata: map[string]any{
|
||||
"brand_id": brandID,
|
||||
"prompt_version": "question_combination_fill_v1",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := s.llm.Generate(ctx, llm.GenerateRequest{
|
||||
Prompt: buildQuestionCombinationFillPrompt(brandCtx, fallback.Region, req),
|
||||
Timeout: questionCombinationFillTimeout,
|
||||
MaxOutputTokens: 900,
|
||||
ResponseFormat: &llm.ResponseFormat{
|
||||
Type: llm.ResponseFormatTypeJSONSchema,
|
||||
Name: "question_combination_fill",
|
||||
Description: "Short word columns for question expansion combination tool.",
|
||||
SchemaJSON: questionCombinationFillSchema,
|
||||
Strict: true,
|
||||
},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
_ = RefundAIPoints(context.Background(), s.pool, s.cache, actor.TenantID, actor.UserID, *reservation, err.Error())
|
||||
return &fallback, nil
|
||||
}
|
||||
|
||||
var payload aiCombinationFillPayload
|
||||
if err := json.Unmarshal([]byte(result.Content), &payload); err != nil {
|
||||
_ = RefundAIPoints(context.Background(), s.pool, s.cache, actor.TenantID, actor.UserID, *reservation, err.Error())
|
||||
return &fallback, nil
|
||||
}
|
||||
|
||||
filled := sanitizeQuestionCombinationFillResult(payload, fallback)
|
||||
if err := CompleteAIPoints(context.Background(), s.pool, actor.TenantID, *reservation, result.Model); err != nil {
|
||||
return nil, response.ErrInternal(50127, "ai_points_commit_failed", "failed to confirm ai points")
|
||||
}
|
||||
filled.AIPointsCharged = reservation.Points
|
||||
if s.cache != nil {
|
||||
cached := filled
|
||||
cached.AIPointsCharged = 0
|
||||
cached.CacheHit = false
|
||||
if raw, err := json.Marshal(cached); err == nil {
|
||||
_ = s.cache.Set(context.Background(), cacheKey, raw, questionCombinationFillTTL)
|
||||
}
|
||||
}
|
||||
return &filled, nil
|
||||
}
|
||||
|
||||
func (s *QuestionExpansionService) ClassifyMetadata(ctx context.Context, brandID int64, texts []string) ([]ClassifiedQuestion, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
brandCtx, err := s.loadQuestionBrandContext(ctx, actor.TenantID, brandID)
|
||||
@@ -485,11 +616,13 @@ func (s *QuestionExpansionService) MaterializeQuestions(ctx context.Context, bra
|
||||
|
||||
func (s *QuestionExpansionService) loadQuestionBrandContext(ctx context.Context, tenantID, brandID int64) (*questionBrandContext, error) {
|
||||
var brandName string
|
||||
var website *string
|
||||
var description *string
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT name
|
||||
SELECT name, website, description
|
||||
FROM brands
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
`, brandID, tenantID).Scan(&brandName)
|
||||
`, brandID, tenantID).Scan(&brandName, &website, &description)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, response.ErrNotFound(40420, "brand_not_found", "brand not found")
|
||||
@@ -525,6 +658,8 @@ func (s *QuestionExpansionService) loadQuestionBrandContext(ctx context.Context,
|
||||
TenantID: tenantID,
|
||||
BrandID: brandID,
|
||||
BrandName: brandName,
|
||||
Website: website,
|
||||
Description: description,
|
||||
PlanCode: plan.PlanCode,
|
||||
CompetitorNames: competitors,
|
||||
}, nil
|
||||
@@ -642,6 +777,317 @@ func optionalQuestionParts(values []string) []string {
|
||||
return parts
|
||||
}
|
||||
|
||||
func buildQuestionCombinationFillFallback(brandCtx *questionBrandContext, regionTerms []string, req QuestionCombinationFillRequest) QuestionCombinationFillResult {
|
||||
brandName := strings.TrimSpace(brandCtx.BrandName)
|
||||
description := strings.TrimSpace(nilToString(brandCtx.Description))
|
||||
category := inferQuestionCombinationCategory(brandName, description)
|
||||
|
||||
region := compactQuestionCombinationTerms(regionTerms, questionCombinationFillMax)
|
||||
if len(region) == 0 {
|
||||
region = compactQuestionCombinationTerms(req.Region, questionCombinationFillMax)
|
||||
}
|
||||
if len(region) == 0 {
|
||||
region = []string{"全国", "本地", "华东"}
|
||||
}
|
||||
|
||||
prefix := compactQuestionCombinationTerms(req.Prefix, questionCombinationFillMax)
|
||||
if len(prefix) == 0 {
|
||||
prefix = []string{"好用的", "专业的", "靠谱的", "热门的"}
|
||||
}
|
||||
|
||||
core := compactQuestionCombinationTerms(req.Core, questionCombinationFillMax)
|
||||
if len(core) == 0 {
|
||||
core = questionCombinationCoreFallback(brandName, description)
|
||||
}
|
||||
|
||||
industry := compactQuestionCombinationTerms(req.Industry, questionCombinationFillMax)
|
||||
if len(industry) == 0 {
|
||||
industry = []string{category, "平台", "工具", "服务商"}
|
||||
}
|
||||
|
||||
suffix := compactQuestionCombinationTerms(req.Suffix, questionCombinationFillMax)
|
||||
if len(suffix) == 0 {
|
||||
suffix = []string{"哪家好", "怎么选", "推荐", "多少钱"}
|
||||
}
|
||||
|
||||
return QuestionCombinationFillResult{
|
||||
Region: compactQuestionCombinationTerms(region, questionCombinationFillMax),
|
||||
Prefix: compactQuestionCombinationTerms(prefix, questionCombinationFillMax),
|
||||
Core: compactQuestionCombinationTerms(core, questionCombinationFillMax),
|
||||
Industry: compactQuestionCombinationTerms(industry, questionCombinationFillMax),
|
||||
Suffix: compactQuestionCombinationTerms(suffix, questionCombinationFillMax),
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeQuestionCombinationFillResult(payload aiCombinationFillPayload, fallback QuestionCombinationFillResult) QuestionCombinationFillResult {
|
||||
return QuestionCombinationFillResult{
|
||||
Region: fillQuestionCombinationTerms(payload.Region, fallback.Region),
|
||||
Prefix: fillQuestionCombinationTerms(payload.Prefix, fallback.Prefix),
|
||||
Core: fillQuestionCombinationTerms(payload.Core, fallback.Core),
|
||||
Industry: fillQuestionCombinationTerms(payload.Industry, fallback.Industry),
|
||||
Suffix: fillQuestionCombinationTerms(payload.Suffix, fallback.Suffix),
|
||||
}
|
||||
}
|
||||
|
||||
func fillQuestionCombinationTerms(values, fallback []string) []string {
|
||||
result := compactQuestionCombinationTerms(values, questionCombinationFillMax)
|
||||
if len(result) >= 3 {
|
||||
return result
|
||||
}
|
||||
for _, item := range fallback {
|
||||
result = append(result, item)
|
||||
result = compactQuestionCombinationTerms(result, questionCombinationFillMax)
|
||||
if len(result) >= 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func compactQuestionCombinationTerms(values []string, limit int) []string {
|
||||
if limit <= 0 {
|
||||
limit = questionCombinationFillMax
|
||||
}
|
||||
result := make([]string, 0, minInt(len(values), limit))
|
||||
seen := map[string]struct{}{}
|
||||
for _, value := range values {
|
||||
term := normalizeQuestionCombinationTerm(value)
|
||||
if term == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(term)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
result = append(result, term)
|
||||
if len(result) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeQuestionCombinationTerm(value string) string {
|
||||
term := strings.TrimSpace(value)
|
||||
term = strings.Trim(term, ",,。!?!?;;、 \t\r\n")
|
||||
term = strings.ReplaceAll(term, " ", "")
|
||||
term = strings.ReplaceAll(term, "\u3000", "")
|
||||
if term == "" || term == "0" || strings.EqualFold(term, "null") {
|
||||
return ""
|
||||
}
|
||||
if utf8.RuneCountInString(term) > 18 {
|
||||
runes := []rune(term)
|
||||
term = string(runes[:18])
|
||||
}
|
||||
return term
|
||||
}
|
||||
|
||||
func questionCombinationRegionTermsFromIPRegion(region string) []string {
|
||||
parts := strings.Split(strings.TrimSpace(region), "|")
|
||||
if len(parts) < 4 {
|
||||
return nil
|
||||
}
|
||||
country := normalizeChineseLocationTerm(parts[0])
|
||||
province := normalizeChineseLocationTerm(parts[2])
|
||||
city := normalizeChineseLocationTerm(parts[3])
|
||||
if country != "" && country != "中国" {
|
||||
return []string{country}
|
||||
}
|
||||
capital := provinceCapitalTerm(province)
|
||||
area := chineseMacroRegion(province)
|
||||
return compactQuestionCombinationTerms([]string{city, capital, area}, 3)
|
||||
}
|
||||
|
||||
func normalizeChineseLocationTerm(value string) string {
|
||||
term := normalizeQuestionCombinationTerm(value)
|
||||
if term == "" || term == "本机" || term == "内网IP" {
|
||||
return ""
|
||||
}
|
||||
replacer := strings.NewReplacer(
|
||||
"特别行政区", "",
|
||||
"维吾尔自治区", "",
|
||||
"壮族自治区", "",
|
||||
"回族自治区", "",
|
||||
"自治区", "",
|
||||
"省", "",
|
||||
"市", "",
|
||||
"地区", "",
|
||||
"盟", "",
|
||||
)
|
||||
return replacer.Replace(term)
|
||||
}
|
||||
|
||||
func provinceCapitalTerm(province string) string {
|
||||
switch province {
|
||||
case "北京":
|
||||
return "北京"
|
||||
case "上海":
|
||||
return "上海"
|
||||
case "天津":
|
||||
return "天津"
|
||||
case "重庆":
|
||||
return "重庆"
|
||||
case "河北":
|
||||
return "石家庄"
|
||||
case "山西":
|
||||
return "太原"
|
||||
case "内蒙古":
|
||||
return "呼和浩特"
|
||||
case "辽宁":
|
||||
return "沈阳"
|
||||
case "吉林":
|
||||
return "长春"
|
||||
case "黑龙江":
|
||||
return "哈尔滨"
|
||||
case "江苏":
|
||||
return "南京"
|
||||
case "浙江":
|
||||
return "杭州"
|
||||
case "安徽":
|
||||
return "合肥"
|
||||
case "福建":
|
||||
return "福州"
|
||||
case "江西":
|
||||
return "南昌"
|
||||
case "山东":
|
||||
return "济南"
|
||||
case "河南":
|
||||
return "郑州"
|
||||
case "湖北":
|
||||
return "武汉"
|
||||
case "湖南":
|
||||
return "长沙"
|
||||
case "广东":
|
||||
return "广州"
|
||||
case "广西":
|
||||
return "南宁"
|
||||
case "海南":
|
||||
return "海口"
|
||||
case "四川":
|
||||
return "成都"
|
||||
case "贵州":
|
||||
return "贵阳"
|
||||
case "云南":
|
||||
return "昆明"
|
||||
case "西藏":
|
||||
return "拉萨"
|
||||
case "陕西":
|
||||
return "西安"
|
||||
case "甘肃":
|
||||
return "兰州"
|
||||
case "青海":
|
||||
return "西宁"
|
||||
case "宁夏":
|
||||
return "银川"
|
||||
case "新疆":
|
||||
return "乌鲁木齐"
|
||||
case "香港":
|
||||
return "香港"
|
||||
case "澳门":
|
||||
return "澳门"
|
||||
case "台湾":
|
||||
return "台北"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func chineseMacroRegion(province string) string {
|
||||
switch province {
|
||||
case "上海", "江苏", "浙江", "安徽", "福建", "江西", "山东", "台湾":
|
||||
return "华东"
|
||||
case "北京", "天津", "河北", "山西", "内蒙古":
|
||||
return "华北"
|
||||
case "河南", "湖北", "湖南":
|
||||
return "华中"
|
||||
case "广东", "广西", "海南", "香港", "澳门":
|
||||
return "华南"
|
||||
case "重庆", "四川", "贵州", "云南", "西藏":
|
||||
return "西南"
|
||||
case "陕西", "甘肃", "青海", "宁夏", "新疆":
|
||||
return "西北"
|
||||
case "辽宁", "吉林", "黑龙江":
|
||||
return "东北"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func questionCombinationCoreFallback(brandName, description string) []string {
|
||||
text := strings.ToLower(strings.TrimSpace(brandName + " " + description))
|
||||
if strings.Contains(text, "geo") || strings.Contains(text, "ai") || strings.Contains(text, "搜索") || strings.Contains(text, "排名") {
|
||||
return []string{"GEO", "AI搜索优化", "AI搜索排名", "品牌可见度"}
|
||||
}
|
||||
if strings.Contains(text, "教育") || strings.Contains(text, "培训") || strings.Contains(text, "课程") {
|
||||
return []string{"课程", "培训", "学习", "机构"}
|
||||
}
|
||||
if strings.Contains(text, "医疗") || strings.Contains(text, "健康") || strings.Contains(text, "诊所") {
|
||||
return []string{"医疗服务", "健康管理", "医生", "诊疗"}
|
||||
}
|
||||
if strings.Contains(text, "家居") || strings.Contains(text, "装修") || strings.Contains(text, "建材") {
|
||||
return []string{"装修", "家居", "建材", "设计"}
|
||||
}
|
||||
return []string{strings.TrimSpace(brandName), "解决方案", "服务", "平台"}
|
||||
}
|
||||
|
||||
func inferQuestionCombinationCategory(brandName, description string) string {
|
||||
text := strings.ToLower(strings.TrimSpace(brandName + " " + description))
|
||||
switch {
|
||||
case strings.Contains(text, "geo") || strings.Contains(text, "seo") || strings.Contains(text, "搜索"):
|
||||
return "营销"
|
||||
case strings.Contains(text, "教育") || strings.Contains(text, "培训") || strings.Contains(text, "课程"):
|
||||
return "教育"
|
||||
case strings.Contains(text, "医疗") || strings.Contains(text, "健康"):
|
||||
return "健康"
|
||||
case strings.Contains(text, "家居") || strings.Contains(text, "装修") || strings.Contains(text, "建材"):
|
||||
return "家居"
|
||||
case strings.Contains(text, "餐饮") || strings.Contains(text, "美食"):
|
||||
return "餐饮"
|
||||
case strings.Contains(text, "旅游") || strings.Contains(text, "酒店"):
|
||||
return "旅游"
|
||||
default:
|
||||
return "服务"
|
||||
}
|
||||
}
|
||||
|
||||
func questionCombinationFillCacheKey(tenantID, brandID int64, brandCtx *questionBrandContext, regionTerms []string, req QuestionCombinationFillRequest) string {
|
||||
raw, _ := json.Marshal(struct {
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
BrandID int64 `json:"brand_id"`
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
BrandName string `json:"brand_name"`
|
||||
Description string `json:"description"`
|
||||
Competitors []string `json:"competitors"`
|
||||
Region []string `json:"region"`
|
||||
Request QuestionCombinationFillRequest `json:"request"`
|
||||
}{
|
||||
TenantID: tenantID,
|
||||
BrandID: brandID,
|
||||
SchemaVersion: "question_combination_fill_v1",
|
||||
BrandName: strings.TrimSpace(brandCtx.BrandName),
|
||||
Description: strings.TrimSpace(nilToString(brandCtx.Description)),
|
||||
Competitors: brandCtx.CompetitorNames,
|
||||
Region: regionTerms,
|
||||
Request: QuestionCombinationFillRequest{
|
||||
Region: compactQuestionCombinationTerms(req.Region, questionCombinationFillMax),
|
||||
Prefix: compactQuestionCombinationTerms(req.Prefix, questionCombinationFillMax),
|
||||
Core: compactQuestionCombinationTerms(req.Core, questionCombinationFillMax),
|
||||
Industry: compactQuestionCombinationTerms(req.Industry, questionCombinationFillMax),
|
||||
Suffix: compactQuestionCombinationTerms(req.Suffix, questionCombinationFillMax),
|
||||
},
|
||||
})
|
||||
sum := sha1.Sum(raw)
|
||||
return "question_combination_fill:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func nilToString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func nonEmptyParts(values ...string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
|
||||
Reference in New Issue
Block a user