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
+99 -45
View File
@@ -30,13 +30,15 @@ type AssistCompetitor struct {
}
type AnalyzeTaskRequest struct {
Locale string `json:"locale"`
BrandID *int64 `json:"brand_id,omitempty"`
BrandName string `json:"brand_name"`
Website string `json:"website,omitempty"`
InputParams map[string]interface{} `json:"input_params,omitempty"`
ExistingKeywords []string `json:"existing_keywords,omitempty"`
ExistingCompetitors []AssistCompetitor `json:"existing_competitors,omitempty"`
Locale string `json:"locale"`
BrandID *int64 `json:"brand_id,omitempty"`
BrandName string `json:"brand_name"`
Website string `json:"website,omitempty"`
BrandQuestion string `json:"brand_question,omitempty"`
SupplementalQuestions []string `json:"supplemental_questions,omitempty"`
InputParams map[string]interface{} `json:"input_params,omitempty"`
ExistingKeywords []string `json:"existing_keywords,omitempty"`
ExistingCompetitors []AssistCompetitor `json:"existing_competitors,omitempty"`
}
type AnalyzeTaskResult struct {
@@ -46,28 +48,32 @@ type AnalyzeTaskResult struct {
}
type TitleTaskRequest struct {
Locale string `json:"locale"`
BrandID *int64 `json:"brand_id,omitempty"`
BrandName string `json:"brand_name"`
Website string `json:"website,omitempty"`
BrandSummary string `json:"brand_summary,omitempty"`
InputParams map[string]interface{} `json:"input_params,omitempty"`
Keywords []string `json:"keywords,omitempty"`
Competitors []AssistCompetitor `json:"competitors,omitempty"`
Locale string `json:"locale"`
BrandID *int64 `json:"brand_id,omitempty"`
BrandName string `json:"brand_name"`
Website string `json:"website,omitempty"`
BrandSummary string `json:"brand_summary,omitempty"`
BrandQuestion string `json:"brand_question,omitempty"`
SupplementalQuestions []string `json:"supplemental_questions,omitempty"`
InputParams map[string]interface{} `json:"input_params,omitempty"`
Keywords []string `json:"keywords,omitempty"`
Competitors []AssistCompetitor `json:"competitors,omitempty"`
}
type OutlineTaskRequest struct {
Locale string `json:"locale"`
BrandID *int64 `json:"brand_id,omitempty"`
BrandName string `json:"brand_name"`
Website string `json:"website,omitempty"`
BrandSummary string `json:"brand_summary,omitempty"`
Title string `json:"title"`
InputParams map[string]interface{} `json:"input_params,omitempty"`
Keywords []string `json:"keywords,omitempty"`
Competitors []AssistCompetitor `json:"competitors,omitempty"`
OutlineSections []string `json:"outline_sections,omitempty"`
KeyPoints string `json:"key_points,omitempty"`
Locale string `json:"locale"`
BrandID *int64 `json:"brand_id,omitempty"`
BrandName string `json:"brand_name"`
Website string `json:"website,omitempty"`
BrandSummary string `json:"brand_summary,omitempty"`
Title string `json:"title"`
BrandQuestion string `json:"brand_question,omitempty"`
SupplementalQuestions []string `json:"supplemental_questions,omitempty"`
InputParams map[string]interface{} `json:"input_params,omitempty"`
Keywords []string `json:"keywords,omitempty"`
Competitors []AssistCompetitor `json:"competitors,omitempty"`
OutlineSections []string `json:"outline_sections,omitempty"`
KeyPoints string `json:"key_points,omitempty"`
}
type OutlineNode struct {
@@ -632,6 +638,8 @@ func normalizeAnalyzeTaskRequest(req AnalyzeTaskRequest) AnalyzeTaskRequest {
}
req.BrandName = strings.TrimSpace(req.BrandName)
req.Website = strings.TrimSpace(req.Website)
req.BrandQuestion = strings.TrimSpace(req.BrandQuestion)
req.SupplementalQuestions = []string{}
req.ExistingKeywords = normalizeStringList(req.ExistingKeywords, 8)
req.ExistingCompetitors = normalizeAssistCompetitors(req.ExistingCompetitors, 8)
req.InputParams = normalizeAssistInputParams(req.InputParams)
@@ -646,7 +654,15 @@ func normalizeTitleTaskRequest(req TitleTaskRequest) TitleTaskRequest {
req.BrandName = strings.TrimSpace(req.BrandName)
req.Website = strings.TrimSpace(req.Website)
req.BrandSummary = strings.TrimSpace(req.BrandSummary)
req.BrandQuestion = strings.TrimSpace(req.BrandQuestion)
req.SupplementalQuestions = normalizeStringList(req.SupplementalQuestions, 3)
req.Keywords = normalizeStringList(req.Keywords, 12)
if req.BrandQuestion != "" {
req.SupplementalQuestions = []string{}
req.Keywords = []string{req.BrandQuestion}
} else if len(req.Keywords) == 0 {
req.Keywords = buildQuestionKeywordContext(req.BrandQuestion, req.SupplementalQuestions, 12)
}
req.Competitors = normalizeAssistCompetitors(req.Competitors, 8)
req.InputParams = normalizeAssistInputParams(req.InputParams)
return req
@@ -662,13 +678,22 @@ func normalizeOutlineTaskRequest(req OutlineTaskRequest) OutlineTaskRequest {
req.BrandSummary = strings.TrimSpace(req.BrandSummary)
req.Title = strings.TrimSpace(req.Title)
req.KeyPoints = strings.TrimSpace(req.KeyPoints)
req.BrandQuestion = strings.TrimSpace(req.BrandQuestion)
req.SupplementalQuestions = normalizeStringList(req.SupplementalQuestions, 3)
req.Keywords = normalizeStringList(req.Keywords, 12)
if len(req.Keywords) == 0 {
req.Keywords = buildQuestionKeywordContext(req.BrandQuestion, req.SupplementalQuestions, 12)
}
req.Competitors = normalizeAssistCompetitors(req.Competitors, 8)
req.OutlineSections = normalizeStringList(req.OutlineSections, 24)
req.InputParams = normalizeAssistInputParams(req.InputParams)
return req
}
func buildQuestionKeywordContext(primaryQuestion string, supplementalQuestions []string, max int) []string {
return normalizeStringList(append([]string{primaryQuestion}, supplementalQuestions...), max)
}
func normalizeAssistInputParams(params map[string]interface{}) map[string]interface{} {
if params == nil {
return map[string]interface{}{}
@@ -682,7 +707,10 @@ func normalizeAssistInputParams(params map[string]interface{}) map[string]interf
}
func hasAnalyzeContext(req AnalyzeTaskRequest) bool {
if req.BrandName != "" || req.Website != "" {
if req.BrandName != "" || req.Website != "" || req.BrandQuestion != "" {
return true
}
if len(req.SupplementalQuestions) > 0 {
return true
}
for _, value := range req.InputParams {
@@ -707,7 +735,10 @@ func hasAnalyzeContext(req AnalyzeTaskRequest) bool {
}
func hasTitleContext(req TitleTaskRequest) bool {
if req.BrandName != "" || req.Website != "" || req.BrandSummary != "" {
if req.BrandName != "" || req.Website != "" || req.BrandSummary != "" || req.BrandQuestion != "" {
return true
}
if len(req.SupplementalQuestions) > 0 {
return true
}
if len(req.Keywords) > 0 || len(req.Competitors) > 0 {
@@ -738,7 +769,10 @@ func hasOutlineContext(req OutlineTaskRequest) bool {
if req.Title == "" || len(req.OutlineSections) == 0 {
return false
}
if req.BrandName != "" || req.Website != "" || req.BrandSummary != "" || req.KeyPoints != "" {
if req.BrandName != "" || req.Website != "" || req.BrandSummary != "" || req.KeyPoints != "" || req.BrandQuestion != "" {
return true
}
if len(req.SupplementalQuestions) > 0 {
return true
}
if len(req.Keywords) > 0 || len(req.Competitors) > 0 {
@@ -835,13 +869,18 @@ func buildOutlineAIPointMeteredText(req OutlineTaskRequest) string {
}
func analyzePromptParams(templateKey, templateName string, req AnalyzeTaskRequest) map[string]interface{} {
keywords := buildQuestionKeywordContext(req.BrandQuestion, nil, 12)
return map[string]interface{}{
"template_key": templateKey,
"template_name": templateName,
"locale": req.Locale,
"brand_name": req.BrandName,
"official_website": req.Website,
"brand_question": req.BrandQuestion,
"primary_question": req.BrandQuestion,
"input_params": req.InputParams,
"keywords": keywords,
"primary_keyword": req.BrandQuestion,
"existing_keywords": req.ExistingKeywords,
"existing_competitors": req.ExistingCompetitors,
}
@@ -855,6 +894,8 @@ func titlePromptParams(templateKey, templateName string, req TitleTaskRequest) m
"brand_name": req.BrandName,
"official_website": req.Website,
"brand_summary": req.BrandSummary,
"brand_question": req.BrandQuestion,
"primary_question": req.BrandQuestion,
"input_params": req.InputParams,
"keywords": req.Keywords,
"competitors": req.Competitors,
@@ -863,7 +904,10 @@ func titlePromptParams(templateKey, templateName string, req TitleTaskRequest) m
}
mergePromptParams(params, req.InputParams)
if len(req.Keywords) > 0 {
if req.BrandQuestion != "" {
params["primary_keyword"] = req.BrandQuestion
params["keyword_count"] = 1
} else if len(req.Keywords) > 0 {
params["primary_keyword"] = req.Keywords[0]
params["keyword_count"] = len(req.Keywords)
}
@@ -892,24 +936,34 @@ func titlePromptParams(templateKey, templateName string, req TitleTaskRequest) m
func outlinePromptParams(templateKey, templateName string, req OutlineTaskRequest) map[string]interface{} {
params := map[string]interface{}{
"template_key": templateKey,
"template_name": templateName,
"locale": req.Locale,
"title": req.Title,
"brand_name": req.BrandName,
"official_website": req.Website,
"brand_summary": req.BrandSummary,
"input_params": req.InputParams,
"keywords": req.Keywords,
"competitors": req.Competitors,
"competitor_count": len(req.Competitors),
"outline_sections": req.OutlineSections,
"key_points": req.KeyPoints,
"current_year": time.Now().Year(),
"template_key": templateKey,
"template_name": templateName,
"locale": req.Locale,
"title": req.Title,
"brand_name": req.BrandName,
"official_website": req.Website,
"brand_summary": req.BrandSummary,
"brand_question": req.BrandQuestion,
"primary_question": req.BrandQuestion,
"supplemental_questions": req.SupplementalQuestions,
"input_params": req.InputParams,
"keywords": req.Keywords,
"competitors": req.Competitors,
"competitor_count": len(req.Competitors),
"outline_sections": req.OutlineSections,
"key_points": req.KeyPoints,
"current_year": time.Now().Year(),
}
mergePromptParams(params, req.InputParams)
if len(req.Keywords) > 0 {
if req.BrandQuestion != "" {
params["primary_keyword"] = req.BrandQuestion
params["keyword_count"] = len(buildQuestionKeywordContext(
req.BrandQuestion,
req.SupplementalQuestions,
12,
))
} else if len(req.Keywords) > 0 {
params["primary_keyword"] = req.Keywords[0]
params["keyword_count"] = len(req.Keywords)
}