feat(admin-brand): add question expansion service and related functionality

- Implemented QuestionExpansionService for generating and materializing questions based on combinations and AI distillation.
- Added question metadata classification logic to infer intent and layer of questions.
- Created API handlers for question expansion operations including combination preview, AI distillation, metadata classification, and materialization.
- Introduced database migrations to support new question-related fields and constraints in brand_questions and brand_keywords tables.
- Added caching mechanism for AI distillation results to improve performance.
- Defined JSON schemas for question distillation responses to ensure data integrity.
This commit is contained in:
2026-05-12 21:53:36 +08:00
parent 77d542c282
commit 37b0b32327
27 changed files with 4619 additions and 908 deletions
+42 -4
View File
@@ -237,10 +237,11 @@ func (p MembershipPlanConfig) QuotaPolicyJSON() ([]byte, error) {
}
type BrandLibraryConfig struct {
FreeBrandLimit int `mapstructure:"free_brand_limit"`
PaidBrandLimit int `mapstructure:"paid_brand_limit"`
MaxKeywords int `mapstructure:"max_keywords"`
MaxQuestionsPerKeyword int `mapstructure:"max_questions_per_keyword"`
FreeBrandLimit int `mapstructure:"free_brand_limit"`
PaidBrandLimit int `mapstructure:"paid_brand_limit"`
MaxKeywords int `mapstructure:"max_keywords"`
MaxQuestionsPerKeyword int `mapstructure:"max_questions_per_keyword"`
QuestionLimitsByPlan map[string]int `mapstructure:"question_limits_by_plan"`
}
func (c BrandLibraryConfig) BrandLimitForPlan(planCode string) int {
@@ -250,6 +251,20 @@ func (c BrandLibraryConfig) BrandLimitForPlan(planCode string) int {
return c.PaidBrandLimit
}
func (c BrandLibraryConfig) QuestionLimitForPlan(planCode string) int {
limits := c.QuestionLimitsByPlan
normalizedPlan := strings.ToLower(strings.TrimSpace(planCode))
if limits != nil {
if value := limits[normalizedPlan]; value > 0 {
return value
}
if value := limits["default"]; value > 0 {
return value
}
}
return 25
}
type QdrantConfig struct {
URL string `mapstructure:"url"`
APIKey string `mapstructure:"api_key"`
@@ -1173,6 +1188,29 @@ func normalizeBrandLibraryConfig(cfg *BrandLibraryConfig) {
if cfg.MaxQuestionsPerKeyword <= 0 {
cfg.MaxQuestionsPerKeyword = 5
}
if cfg.QuestionLimitsByPlan == nil {
cfg.QuestionLimitsByPlan = map[string]int{}
}
normalized := make(map[string]int, len(cfg.QuestionLimitsByPlan)+4)
for key, value := range cfg.QuestionLimitsByPlan {
trimmed := strings.ToLower(strings.TrimSpace(key))
if trimmed != "" && value > 0 {
normalized[trimmed] = value
}
}
if normalized["default"] <= 0 {
normalized["default"] = 25
}
if normalized["free"] <= 0 {
normalized["free"] = 5
}
if normalized["plus"] <= 0 {
normalized["plus"] = 25
}
if normalized["pro"] <= 0 {
normalized["pro"] = 50
}
cfg.QuestionLimitsByPlan = normalized
}
func normalizeMembershipConfig(cfg *MembershipConfig) {
+1 -1
View File
@@ -62,7 +62,7 @@ func Diff(previous, current *Config) []FieldChange {
if !reflect.DeepEqual(previous.Membership, current.Membership) {
addChange("membership", true)
}
if previous.BrandLibrary != current.BrandLibrary {
if !reflect.DeepEqual(previous.BrandLibrary, current.BrandLibrary) {
addChange("brand_library", true)
}
if previous.Qdrant != current.Qdrant {
@@ -175,10 +175,14 @@ var routeDocs = map[string]routeDoc{
"PUT /api/tenant/brands/:id/keywords/:kid": {"更新关键词", "修改关键词文本/分组。"},
"DELETE /api/tenant/brands/:id/keywords/:kid": {"删除关键词", "删除某条关键词。"},
"GET /api/tenant/brands/:id/questions": {"品牌问题列表", "返回品牌下的监控问题,可按 keyword_id 过滤。"},
"POST /api/tenant/brands/:id/questions": {"新增监控问题", "为品牌添加一条 GEO 监控问题。"},
"PUT /api/tenant/brands/:id/questions/:qid": {"更新监控问题", "修改问题文本或所属关键词。"},
"DELETE /api/tenant/brands/:id/questions/:qid": {"删除监控问题", "删除某条监控问题。"},
"GET /api/tenant/brands/:id/questions": {"品牌问题列表", "返回品牌下的监控问题,可按 keyword_id 过滤。"},
"POST /api/tenant/brands/:id/questions": {"新增监控问题", "为品牌添加一条 GEO 监控问题。"},
"POST /api/tenant/brands/:id/questions/combination-preview": {"拓词工具预览", "按地域词、前缀词、核心词、行业词、后缀词组合生成问题候选,仅预览不入库。"},
"POST /api/tenant/brands/:id/questions/ai-distill": {"AI 扩展问题", "围绕品牌和主题生成问题候选,使用结构化输出并按 AI 点计费。"},
"POST /api/tenant/brands/:id/questions/classify-metadata": {"问题元数据分类", "批量为问题文本推断 layer 和 intent 元数据。"},
"POST /api/tenant/brands/:id/questions/materialize": {"保存问题候选", "将用户选中的问题候选保存到当前品牌问题集,执行配额、去重和审计。"},
"PUT /api/tenant/brands/:id/questions/:qid": {"更新监控问题", "修改问题文本或所属关键词。"},
"DELETE /api/tenant/brands/:id/questions/:qid": {"删除监控问题", "删除某条监控问题。"},
"GET /api/tenant/brands/:id/competitors": {"竞品列表", "返回品牌下登记的竞品。"},
"POST /api/tenant/brands/:id/competitors": {"新增竞品", "为品牌添加竞品记录。"},