feat(questions): make brand questions the primary monitoring & template axis
Deployment Config CI / Deployment Config (push) Successful in 29s
Frontend CI / Frontend (push) Successful in 4m4s
Backend CI / Backend (push) Failing after 7m12s

- 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:
2026-05-13 15:59:39 +08:00
parent 37b0b32327
commit 1eae6fb6d4
36 changed files with 2119 additions and 412 deletions
@@ -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 {