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
+3
View File
@@ -56,6 +56,7 @@ type App struct {
DesktopDispatch *stream.DesktopDispatchHub
Cache cache.Cache
BrandService *tenantapp.BrandService
QuestionExpansion *tenantapp.QuestionExpansionService
MonitoringService *tenantapp.MonitoringService
KolProfiles repository.KolProfileRepository
KolPackages repository.KolPackageRepository
@@ -145,6 +146,7 @@ func New(configPath string) (*App, error) {
DeleteScanCount: cfg.Cache.DeleteScanCount,
})
brandService := tenantapp.NewBrandService(pool, monitoringPool, auditLogs, cfg.BrandLibrary).WithCache(appCache)
questionExpansion := tenantapp.NewQuestionExpansionService(pool, llmClient, brandService).WithCache(appCache)
monitoringService := tenantapp.NewMonitoringService(pool, monitoringPool, mqClient, cfg.MonitoringDispatch, cfg.BrandLibrary, logger).WithRedis(rdb)
kolProfiles := repository.NewKolProfileRepository(pool)
kolPackages := repository.NewKolPackageRepository(pool)
@@ -214,6 +216,7 @@ func New(configPath string) (*App, error) {
DesktopDispatch: desktopDispatch,
Cache: appCache,
BrandService: brandService,
QuestionExpansion: questionExpansion,
MonitoringService: monitoringService,
KolProfiles: kolProfiles,
KolPackages: kolPackages,
+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": {"新增竞品", "为品牌添加竞品记录。"},
@@ -23,6 +23,7 @@ const (
AIUsageTypeTemplateOutlineGenerate = "template_outline_generate"
AIUsageTypeKolPromptGenerate = "kol_prompt_generate"
AIUsageTypeKolPromptOptimize = "kol_prompt_optimize"
AIUsageTypeQuestionDistill = "question_distill"
aiPointsQuotaType = "ai_points"
)
+222 -75
View File
@@ -68,15 +68,16 @@ type BrandRequest struct {
}
type BrandResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Website *string `json:"website"`
Description *string `json:"description"`
Status string `json:"status"`
KeywordCount int `json:"keyword_count"`
QuestionCount int `json:"question_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID int64 `json:"id"`
Name string `json:"name"`
Website *string `json:"website"`
Description *string `json:"description"`
Status string `json:"status"`
KeywordCount int `json:"keyword_count"`
QuestionCount int `json:"question_count"`
CompetitorCount int `json:"competitor_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type BrandLibrarySummaryResponse struct {
@@ -88,7 +89,11 @@ type BrandLibrarySummaryResponse struct {
MaxKeywords int `json:"max_keywords"`
UsedKeywords int `json:"used_keywords"`
RemainingKeywords int `json:"remaining_keywords"`
MaxQuestions int `json:"max_questions"`
UsedQuestions int `json:"used_questions"`
RemainingQuestions int `json:"remaining_questions"`
MaxQuestionsPerKeyword int `json:"max_questions_per_keyword"`
MaxQuestionsPerBrand int `json:"max_questions_per_brand"`
}
func (s *BrandService) List(ctx context.Context) ([]BrandResponse, error) {
@@ -117,9 +122,17 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(50010, "create_failed", "failed to begin brand transaction")
}
defer func() {
_ = tx.Rollback(ctx)
}()
var id int64
var ca interface{}
err = s.pool.QueryRow(ctx, `
err = tx.QueryRow(ctx, `
WITH usage AS (
SELECT COUNT(*)::INT AS used_brands
FROM brands
@@ -141,6 +154,13 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp
return nil, response.ErrInternal(50010, "create_failed", "failed to create brand")
}
if _, err := ensureDefaultQuestionBucketTx(ctx, tx, actor.TenantID, id, req.Name); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50010, "create_failed", "failed to commit brand")
}
afterJSON, _ := json.Marshal(map[string]interface{}{"id": id, "name": req.Name})
result := "success"
resourceType := "brand"
@@ -159,14 +179,15 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp
invalidateBrandCaches(ctx, s.cache, actor.TenantID, id)
return &BrandResponse{
ID: id,
Name: req.Name,
Website: req.Website,
Description: req.Description,
Status: "active",
KeywordCount: 0,
QuestionCount: 0,
CreatedAt: fmt.Sprintf("%v", ca),
ID: id,
Name: req.Name,
Website: req.Website,
Description: req.Description,
Status: "active",
KeywordCount: 0,
QuestionCount: 0,
CompetitorCount: 0,
CreatedAt: fmt.Sprintf("%v", ca),
}, nil
}
@@ -470,29 +491,47 @@ func (s *BrandService) CreateQuestion(ctx context.Context, brandID int64, req Qu
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(50010, "create_failed", "failed to begin question transaction")
}
defer func() {
_ = tx.Rollback(ctx)
}()
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`, tenantQuestionQuotaLockKey(actor.TenantID), "questions"); err != nil {
return nil, response.ErrInternal(50010, "create_failed", "failed to lock question quota")
}
var questionID int64
var ca interface{}
err = s.pool.QueryRow(ctx, `
err = tx.QueryRow(ctx, `
WITH usage AS (
SELECT COUNT(*)::INT AS used_questions
FROM brand_questions
WHERE tenant_id = $1 AND keyword_id = $3 AND deleted_at IS NULL
WHERE tenant_id = $1 AND deleted_at IS NULL
)
INSERT INTO brand_questions (tenant_id, brand_id, keyword_id, question_text, status)
SELECT $1, $2, $3, $4, 'active'
FROM usage
WHERE usage.used_questions < $5
RETURNING id, created_at
`, actor.TenantID, brandID, req.KeywordID, req.QuestionText, summary.MaxQuestionsPerKeyword).Scan(&questionID, &ca)
`, actor.TenantID, brandID, req.KeywordID, req.QuestionText, summary.MaxQuestions).Scan(&questionID, &ca)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrConflict(40906, "question_limit_reached", fmt.Sprintf("each keyword allows up to %d questions", summary.MaxQuestionsPerKeyword))
return nil, response.ErrConflict(40906, "question_limit_reached", fmt.Sprintf("current account allows up to %d questions", summary.MaxQuestions))
}
if isUniqueQuestionConstraintError(err) {
return nil, response.ErrConflict(40907, "question_exists", "question already exists for this brand")
}
if isForeignKeyConstraintError(err) {
return nil, response.ErrNotFound(40421, "keyword_not_found", "keyword not found")
}
return nil, response.ErrInternal(50010, "create_failed", "failed to create question")
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50010, "create_failed", "failed to commit question")
}
invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID)
return &QuestionResponse{
@@ -516,11 +555,26 @@ func (s *BrandService) UpdateQuestion(ctx context.Context, brandID, questionID i
return response.ErrBadRequest(40001, "invalid_params", "question_text is required")
}
classified, err := s.classifyQuestionForUpdate(ctx, actor.TenantID, brandID, req.QuestionText)
if err != nil {
return err
}
tag, err := s.pool.Exec(ctx, `
UPDATE brand_questions SET question_text = $1, updated_at = NOW()
WHERE id = $2 AND brand_id = $3 AND tenant_id = $4 AND deleted_at IS NULL
`, req.QuestionText, questionID, brandID, actor.TenantID)
if err != nil || tag.RowsAffected() == 0 {
UPDATE brand_questions
SET question_text = $1,
layer = $2,
intent = $3,
updated_at = NOW()
WHERE id = $4 AND brand_id = $5 AND tenant_id = $6 AND deleted_at IS NULL
`, req.QuestionText, classified.Layer, classified.Intent, questionID, brandID, actor.TenantID)
if err != nil {
if isUniqueQuestionConstraintError(err) {
return response.ErrConflict(40907, "question_exists", "question already exists for this brand")
}
return response.ErrInternal(50010, "update_failed", "failed to update question")
}
if tag.RowsAffected() == 0 {
return response.ErrNotFound(40422, "question_not_found", "question not found")
}
invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID)
@@ -641,24 +695,35 @@ func (s *BrandService) loadBrands(ctx context.Context, tenantID int64) ([]BrandR
b.website,
b.description,
b.status,
COALESCE(stats.keyword_count, 0) AS keyword_count,
COALESCE(stats.question_count, 0) AS question_count,
COALESCE(keyword_stats.keyword_count, 0) AS keyword_count,
COALESCE(question_stats.question_count, 0) AS question_count,
COALESCE(competitor_stats.competitor_count, 0) AS competitor_count,
b.created_at,
b.updated_at
FROM brands b
LEFT JOIN LATERAL (
SELECT
COUNT(DISTINCT k.id)::INT AS keyword_count,
COUNT(q.id)::INT AS question_count
COUNT(DISTINCT k.id)::INT AS keyword_count
FROM brand_keywords k
LEFT JOIN brand_questions q
ON q.keyword_id = k.id
AND q.tenant_id = b.tenant_id
AND q.deleted_at IS NULL
WHERE k.brand_id = b.id
AND k.tenant_id = b.tenant_id
AND COALESCE(k.source, 'manual') <> 'auto'
AND k.deleted_at IS NULL
) stats ON true
) keyword_stats ON true
LEFT JOIN LATERAL (
SELECT COUNT(*)::INT AS question_count
FROM brand_questions q
WHERE q.brand_id = b.id
AND q.tenant_id = b.tenant_id
AND q.deleted_at IS NULL
) question_stats ON true
LEFT JOIN LATERAL (
SELECT COUNT(*)::INT AS competitor_count
FROM competitors c
WHERE c.brand_id = b.id
AND c.tenant_id = b.tenant_id
AND c.deleted_at IS NULL
) competitor_stats ON true
WHERE b.tenant_id = $1 AND b.deleted_at IS NULL
ORDER BY b.created_at DESC
`, tenantID)
@@ -672,7 +737,7 @@ func (s *BrandService) loadBrands(ctx context.Context, tenantID int64) ([]BrandR
var item BrandResponse
var createdAt interface{}
var updatedAt interface{}
if err := rows.Scan(&item.ID, &item.Name, &item.Website, &item.Description, &item.Status, &item.KeywordCount, &item.QuestionCount, &createdAt, &updatedAt); err != nil {
if err := rows.Scan(&item.ID, &item.Name, &item.Website, &item.Description, &item.Status, &item.KeywordCount, &item.QuestionCount, &item.CompetitorCount, &createdAt, &updatedAt); err != nil {
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
}
item.CreatedAt = fmt.Sprintf("%v", createdAt)
@@ -693,26 +758,37 @@ func (s *BrandService) loadBrandDetail(ctx context.Context, tenantID, brandID in
b.website,
b.description,
b.status,
COALESCE(stats.keyword_count, 0) AS keyword_count,
COALESCE(stats.question_count, 0) AS question_count,
COALESCE(keyword_stats.keyword_count, 0) AS keyword_count,
COALESCE(question_stats.question_count, 0) AS question_count,
COALESCE(competitor_stats.competitor_count, 0) AS competitor_count,
b.created_at,
b.updated_at
FROM brands b
LEFT JOIN LATERAL (
SELECT
COUNT(DISTINCT k.id)::INT AS keyword_count,
COUNT(q.id)::INT AS question_count
COUNT(DISTINCT k.id)::INT AS keyword_count
FROM brand_keywords k
LEFT JOIN brand_questions q
ON q.keyword_id = k.id
AND q.tenant_id = b.tenant_id
AND q.deleted_at IS NULL
WHERE k.brand_id = b.id
AND k.tenant_id = b.tenant_id
AND COALESCE(k.source, 'manual') <> 'auto'
AND k.deleted_at IS NULL
) stats ON true
) keyword_stats ON true
LEFT JOIN LATERAL (
SELECT COUNT(*)::INT AS question_count
FROM brand_questions q
WHERE q.brand_id = b.id
AND q.tenant_id = b.tenant_id
AND q.deleted_at IS NULL
) question_stats ON true
LEFT JOIN LATERAL (
SELECT COUNT(*)::INT AS competitor_count
FROM competitors c
WHERE c.brand_id = b.id
AND c.tenant_id = b.tenant_id
AND c.deleted_at IS NULL
) competitor_stats ON true
WHERE b.id = $1 AND b.tenant_id = $2 AND b.deleted_at IS NULL
`, brandID, tenantID).Scan(&item.ID, &item.Name, &item.Website, &item.Description, &item.Status, &item.KeywordCount, &item.QuestionCount, &createdAt, &updatedAt)
`, brandID, tenantID).Scan(&item.ID, &item.Name, &item.Website, &item.Description, &item.Status, &item.KeywordCount, &item.QuestionCount, &item.CompetitorCount, &createdAt, &updatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, false, nil
@@ -726,8 +802,12 @@ func (s *BrandService) loadBrandDetail(ctx context.Context, tenantID, brandID in
func (s *BrandService) loadBrandKeywords(ctx context.Context, tenantID, brandID int64) ([]KeywordResponse, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, brand_id, name, status, created_at FROM brand_keywords
WHERE brand_id = $1 AND tenant_id = $2 AND deleted_at IS NULL ORDER BY created_at DESC
SELECT id, brand_id, name, status, created_at FROM brand_keywords
WHERE brand_id = $1
AND tenant_id = $2
AND deleted_at IS NULL
AND COALESCE(source, 'manual') <> 'auto'
ORDER BY created_at DESC
`, brandID, tenantID)
if err != nil {
return nil, response.ErrInternal(50010, "query_failed", "failed to list keywords")
@@ -808,14 +888,14 @@ func (s *BrandService) loadBrandCompetitors(ctx context.Context, tenantID, brand
}
type brandLibraryPlan struct {
PlanCode string
PlanName string
MaxBrands int
PlanCode string
PlanName string
}
type brandLibraryUsage struct {
BrandCount int
KeywordCount int
BrandCount int
KeywordCount int
QuestionCount int
}
func (s *BrandService) loadBrandLibrarySummary(ctx context.Context, tenantID int64) (*BrandLibrarySummaryResponse, error) {
@@ -831,10 +911,8 @@ func (s *BrandService) loadBrandLibrarySummary(ctx context.Context, tenantID int
limits := s.currentLimits()
maxBrands := limits.BrandLimitForPlan(plan.PlanCode)
if plan.MaxBrands > 0 {
maxBrands = plan.MaxBrands
}
maxKeywords := limits.MaxKeywords
maxQuestions := limits.QuestionLimitForPlan(plan.PlanCode)
return &BrandLibrarySummaryResponse{
PlanCode: plan.PlanCode,
@@ -845,21 +923,22 @@ func (s *BrandService) loadBrandLibrarySummary(ctx context.Context, tenantID int
MaxKeywords: maxKeywords,
UsedKeywords: usage.KeywordCount,
RemainingKeywords: maxInt(maxKeywords-usage.KeywordCount, 0),
MaxQuestions: maxQuestions,
UsedQuestions: usage.QuestionCount,
RemainingQuestions: maxInt(maxQuestions-usage.QuestionCount, 0),
MaxQuestionsPerKeyword: limits.MaxQuestionsPerKeyword,
MaxQuestionsPerBrand: maxQuestions,
}, nil
}
func (s *BrandService) loadBrandLibraryPlan(ctx context.Context, tenantID int64) (*brandLibraryPlan, error) {
limits := s.currentLimits()
plan := &brandLibraryPlan{
PlanCode: "free",
PlanName: "",
MaxBrands: limits.BrandLimitForPlan("free"),
PlanCode: "free",
PlanName: "",
}
var quotaPolicyJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT p.plan_code, p.name, p.quota_policy_json
SELECT p.plan_code, p.name
FROM tenant_plan_subscriptions s
JOIN plans p ON p.id = s.plan_id
WHERE s.tenant_id = $1
@@ -869,22 +948,13 @@ func (s *BrandService) loadBrandLibraryPlan(ctx context.Context, tenantID int64)
AND s.end_at > $2
ORDER BY s.start_at DESC
LIMIT 1
`, tenantID, time.Now().UTC()).Scan(&plan.PlanCode, &plan.PlanName, &quotaPolicyJSON)
`, tenantID, time.Now().UTC()).Scan(&plan.PlanCode, &plan.PlanName)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return plan, nil
}
return nil, response.ErrInternal(50010, "query_failed", "failed to load active plan")
}
var quotaPolicy struct {
BrandLimit int `json:"brand_limit"`
}
if len(quotaPolicyJSON) > 0 {
_ = json.Unmarshal(quotaPolicyJSON, &quotaPolicy)
}
if quotaPolicy.BrandLimit > 0 {
plan.MaxBrands = quotaPolicy.BrandLimit
}
return plan, nil
}
@@ -893,8 +963,9 @@ func (s *BrandService) loadBrandLibraryUsage(ctx context.Context, tenantID int64
err := s.pool.QueryRow(ctx, `
SELECT
(SELECT COUNT(*)::INT FROM brands WHERE tenant_id = $1 AND deleted_at IS NULL) AS brand_count,
(SELECT COUNT(*)::INT FROM brand_keywords WHERE tenant_id = $1 AND deleted_at IS NULL) AS keyword_count
`, tenantID).Scan(&usage.BrandCount, &usage.KeywordCount)
(SELECT COUNT(*)::INT FROM brand_keywords WHERE tenant_id = $1 AND deleted_at IS NULL AND COALESCE(source, 'manual') <> 'auto') AS keyword_count,
(SELECT COUNT(*)::INT FROM brand_questions WHERE tenant_id = $1 AND deleted_at IS NULL) AS question_count
`, tenantID).Scan(&usage.BrandCount, &usage.KeywordCount, &usage.QuestionCount)
if err != nil {
return nil, response.ErrInternal(50010, "query_failed", "failed to load brand library usage")
}
@@ -919,7 +990,7 @@ func (s *BrandService) brandExists(ctx context.Context, tenantID, brandID int64)
func (s *BrandService) keywordExistsForBrand(ctx context.Context, tenantID, brandID, keywordID int64) (bool, error) {
var exists bool
err := s.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT EXISTS (
SELECT 1
FROM brand_keywords
WHERE id = $1
@@ -934,6 +1005,82 @@ func (s *BrandService) keywordExistsForBrand(ctx context.Context, tenantID, bran
return exists, nil
}
func (s *BrandService) classifyQuestionForUpdate(ctx context.Context, tenantID, brandID int64, text string) (ClassifiedQuestion, error) {
var brandName string
if err := s.pool.QueryRow(ctx, `
SELECT name
FROM brands
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, brandID, tenantID).Scan(&brandName); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ClassifiedQuestion{}, response.ErrNotFound(40420, "brand_not_found", "brand not found")
}
return ClassifiedQuestion{}, response.ErrInternal(50010, "query_failed", "failed to load brand")
}
rows, err := s.pool.Query(ctx, `
SELECT name
FROM competitors
WHERE brand_id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, brandID, tenantID)
if err != nil {
return ClassifiedQuestion{}, response.ErrInternal(50010, "query_failed", "failed to load competitors")
}
defer rows.Close()
competitors := make([]string, 0)
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return ClassifiedQuestion{}, response.ErrInternal(50010, "scan_failed", err.Error())
}
competitors = append(competitors, name)
}
return classifyQuestionText(text, brandName, competitors), nil
}
func ensureDefaultQuestionBucketTx(ctx context.Context, tx pgx.Tx, tenantID, brandID int64, brandName string) (int64, error) {
var id int64
err := tx.QueryRow(ctx, `
SELECT id
FROM brand_keywords
WHERE tenant_id = $1
AND brand_id = $2
AND source = 'auto'
AND deleted_at IS NULL
ORDER BY id ASC
LIMIT 1
FOR UPDATE
`, tenantID, brandID).Scan(&id)
if err == nil {
return id, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return 0, response.ErrInternal(50010, "default_bucket_failed", "failed to load default question bucket")
}
err = tx.QueryRow(ctx, `
INSERT INTO brand_keywords (
tenant_id, brand_id, name, status,
layer, seed_word, source
) VALUES (
$1, $2, '__default_questions__', 'active',
'L1', $3, 'auto'
)
ON CONFLICT (brand_id, name) WHERE deleted_at IS NULL
DO UPDATE
SET source = 'auto',
layer = 'L1',
seed_word = COALESCE(brand_keywords.seed_word, EXCLUDED.seed_word),
updated_at = brand_keywords.updated_at
RETURNING id
`, tenantID, brandID, brandName).Scan(&id)
if err != nil {
return 0, response.ErrInternal(50010, "default_bucket_failed", "failed to create default question bucket")
}
return id, nil
}
func isUniqueConstraintError(err error, constraint string) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == constraint
@@ -88,6 +88,10 @@ func brandLibrarySummaryCacheKey(tenantID int64) string {
return fmt.Sprintf("brand:library_summary:%d", tenantID)
}
func tenantQuestionQuotaLockKey(tenantID int64) string {
return fmt.Sprintf("tenant:%d:question_quota", tenantID)
}
func brandDetailCacheKey(tenantID, brandID int64) string {
return fmt.Sprintf("brand:detail:%d:%d", tenantID, brandID)
}
@@ -0,0 +1,68 @@
package app
import (
"encoding/json"
"fmt"
"strings"
)
var questionDistillSchema = []byte(`{
"type": "object",
"additionalProperties": false,
"properties": {
"candidates": {
"type": "array",
"maxItems": 20,
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"text": {
"type": "string",
"minLength": 4,
"maxLength": 80
},
"intent": {
"type": "string",
"enum": ["informational", "evaluative", "decisional"]
},
"layer": {
"type": "string",
"enum": ["L1", "L2", "L3", "L4", "L5"]
}
},
"required": ["text", "intent", "layer"]
}
}
},
"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(`你是一个 GEOGenerative Engine Optimization)资深策略师。
请围绕当前品牌和输入主题,生成中国用户在 AI 搜索里真实会问的问题。
【输入】
- 当前品牌: %s
- 竞品列表: %s
- 主题: %s
【约束】
1. candidates 长度不超过 20。
2. 问题必须是自然口语问句,避免口号、短词和营销文案。
3. 尽量覆盖 informational、evaluative、decisional 三类意图。
4. 至少 4 条包含地域、价位、人群或场景修饰。
5. 每条问题尽量不超过 40 个中文字符。
6. 严格按响应格式输出,不要解释。`,
strings.TrimSpace(brandName),
competitors,
strings.TrimSpace(seedTopic),
)
}
@@ -0,0 +1,691 @@
package app
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"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/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
)
type QuestionExpansionService struct {
pool *pgxpool.Pool
llm llm.Client
brand *BrandService
cache sharedcache.Cache
}
func NewQuestionExpansionService(pool *pgxpool.Pool, llmClient llm.Client, brand *BrandService) *QuestionExpansionService {
return &QuestionExpansionService{
pool: pool,
llm: llmClient,
brand: brand,
}
}
func (s *QuestionExpansionService) WithCache(c sharedcache.Cache) *QuestionExpansionService {
s.cache = c
return s
}
type QuestionCombinationRequest struct {
Region []string `json:"region"`
Prefix []string `json:"prefix"`
Core []string `json:"core" binding:"required"`
Industry []string `json:"industry" binding:"required"`
Suffix []string `json:"suffix"`
MaxItems int `json:"max_items"`
}
type QuestionDistillRequest struct {
SeedTopic string `json:"seed_topic" binding:"required,min=2"`
}
type QuestionCandidate struct {
Text string `json:"text"`
Layer string `json:"layer"`
Intent string `json:"intent"`
Source string `json:"source"`
MissingSuffix bool `json:"missing_suffix"`
TooShort bool `json:"too_short"`
Duplicate bool `json:"duplicate"`
SuggestSkip bool `json:"suggest_skip"`
}
type QuestionCandidateResult struct {
Candidates []QuestionCandidate `json:"candidates"`
AIPointsCharged int `json:"ai_points_charged,omitempty"`
CacheHit bool `json:"cache_hit,omitempty"`
Truncated bool `json:"truncated,omitempty"`
}
type MaterializeQuestionsRequest struct {
Source string `json:"source"`
Questions []MaterializeQuestion `json:"questions" binding:"required,min=1"`
}
type MaterializeQuestion struct {
Text string `json:"text" binding:"required"`
}
type MaterializeQuestionsResult struct {
CreatedQuestions int `json:"created_questions"`
SkippedQuestions []SkipReason `json:"skipped_questions"`
}
type SkipReason struct {
Text string `json:"text"`
Reason string `json:"reason"`
}
type questionBrandContext struct {
TenantID int64
BrandID int64
BrandName string
PlanCode string
CompetitorNames []string
}
type aiDistillPayload struct {
Candidates []struct {
Text string `json:"text"`
Intent string `json:"intent"`
Layer string `json:"layer"`
} `json:"candidates"`
}
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)
if err != nil {
return nil, err
}
core := normalizeQuestionParts(req.Core)
industry := normalizeQuestionParts(req.Industry)
if len(core) == 0 {
return nil, response.ErrBadRequest(40091, "invalid_params", "core_required")
}
if len(industry) == 0 {
return nil, response.ErrBadRequest(40091, "invalid_params", "industry_required")
}
region := optionalQuestionParts(req.Region)
prefix := optionalQuestionParts(req.Prefix)
suffix := optionalQuestionParts(req.Suffix)
limit := req.MaxItems
if limit <= 0 || limit > questionCombinationMaxItems {
limit = questionCombinationMaxItems
}
existing, err := s.loadExistingQuestionKeys(ctx, actor.TenantID, brandID)
if err != nil {
return nil, err
}
candidates := make([]QuestionCandidate, 0, minInt(limit, 64))
seen := map[string]struct{}{}
truncated := false
for _, rg := range region {
for _, pf := range prefix {
for _, co := range core {
for _, in := range industry {
for _, sf := range suffix {
text := strings.TrimSpace(strings.Join(nonEmptyParts(rg, pf, co, in, sf), ""))
if text == "" {
continue
}
if sf != "" && !strings.HasSuffix(text, "?") && !strings.HasSuffix(text, "") {
if !strings.Contains(sf, "?") && !strings.Contains(sf, "") {
text += ""
}
}
key := normalizeQuestionKey(text)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if len(candidates) >= limit {
truncated = true
continue
}
candidates = append(candidates, buildCandidate(text, QuestionSourceCombination, sf == "", existing, brandCtx))
}
}
}
}
}
return &QuestionCandidateResult{Candidates: candidates, Truncated: truncated}, nil
}
func (s *QuestionExpansionService) GenerateByAIDistill(ctx context.Context, brandID int64, req QuestionDistillRequest) (*QuestionCandidateResult, error) {
actor := auth.MustActor(ctx)
seedTopic := normalizeQuestionText(req.SeedTopic)
if utf8.RuneCountInString(seedTopic) < 2 {
return nil, response.ErrBadRequest(40091, "invalid_params", "seed_topic is required")
}
brandCtx, err := s.loadQuestionBrandContext(ctx, actor.TenantID, brandID)
if err != nil {
return nil, err
}
existing, err := s.loadExistingQuestionKeys(ctx, actor.TenantID, brandID)
if err != nil {
return nil, err
}
cacheKey := questionDistillCacheKey(actor.TenantID, brandID, questionDistillPromptVersion, 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
if json.Unmarshal(raw, &cached) == nil {
return &QuestionCandidateResult{Candidates: cached, CacheHit: true}, nil
}
}
}
if s.llm == nil {
return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", "llm client is not configured")
}
if err := s.llm.Validate(); err != nil {
return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error())
}
resourceType := "question_distill"
resourceUID := cacheKey
reservation, err := ReserveAIPoints(ctx, s.pool, s.cache, AIPointReserveInput{
TenantID: actor.TenantID,
OperatorID: actor.UserID,
UsageType: AIUsageTypeQuestionDistill,
ResourceType: &resourceType,
ResourceUID: &resourceUID,
MeteredText: seedTopic,
FixedPoints: 1,
Metadata: map[string]any{
"brand_id": brandID,
"seed_topic": seedTopic,
"prompt_version": questionDistillPromptVersion,
},
})
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,
MaxOutputTokens: 1600,
ResponseFormat: &llm.ResponseFormat{
Type: llm.ResponseFormatTypeJSONSchema,
Name: "question_distill_candidates",
Description: "Question expansion candidates for a brand.",
SchemaJSON: questionDistillSchema,
Strict: true,
},
}, 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) {
return nil, response.ErrServiceUnavailable(50312, "llm_timeout", "AI generation timed out")
}
return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error())
}
var payload aiDistillPayload
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 nil, response.ErrBadRequest(50210, "llm_invalid_output", "AI output could not be parsed")
}
candidates := make([]QuestionCandidate, 0, minInt(len(payload.Candidates), questionAIDistillMaxItems))
seen := map[string]struct{}{}
for _, item := range payload.Candidates {
if len(candidates) >= questionAIDistillMaxItems {
break
}
text := normalizeQuestionText(item.Text)
key := normalizeQuestionKey(text)
if text == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
candidates = append(candidates, buildCandidate(text, QuestionSourceAIDistill, false, existing, brandCtx))
}
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")
}
if s.cache != nil {
if raw, err := json.Marshal(candidates); err == nil {
_ = s.cache.Set(context.Background(), cacheKey, raw, questionDistillCacheTTL)
}
}
return &QuestionCandidateResult{
Candidates: candidates,
AIPointsCharged: reservation.Points,
}, 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)
if err != nil {
return nil, err
}
result := make([]ClassifiedQuestion, 0, len(texts))
for _, text := range texts {
trimmed := normalizeQuestionText(text)
if trimmed == "" {
continue
}
result = append(result, classifyQuestionText(trimmed, brandCtx.BrandName, brandCtx.CompetitorNames))
}
return result, nil
}
func (s *QuestionExpansionService) MaterializeQuestions(ctx context.Context, brandID int64, req MaterializeQuestionsRequest) (*MaterializeQuestionsResult, error) {
actor := auth.MustActor(ctx)
source := strings.TrimSpace(req.Source)
if source == "" {
source = QuestionSourceManual
}
if !isValidExternalQuestionSource(source) {
return nil, response.ErrBadRequest(40092, "invalid_enum", "source is invalid")
}
if len(req.Questions) == 0 {
return nil, response.ErrBadRequest(40093, "no_valid_questions", "questions is required")
}
brandCtx, err := s.loadQuestionBrandContext(ctx, actor.TenantID, brandID)
if err != nil {
return nil, err
}
existing, err := s.loadExistingQuestionKeys(ctx, actor.TenantID, brandID)
if err != nil {
return nil, err
}
currentCount, err := s.countTenantQuestions(ctx, actor.TenantID)
if err != nil {
return nil, err
}
maxQuestions := s.brand.currentLimits().QuestionLimitForPlan(brandCtx.PlanCode)
remaining := maxQuestions - currentCount
if remaining < 0 {
remaining = 0
}
type acceptedQuestion struct {
Text string `json:"text"`
Layer string `json:"layer"`
Intent string `json:"intent"`
Source string `json:"source"`
}
accepted := make([]acceptedQuestion, 0, len(req.Questions))
skipped := make([]SkipReason, 0)
inBatch := map[string]struct{}{}
for _, item := range req.Questions {
text := normalizeQuestionText(item.Text)
key := normalizeQuestionKey(text)
if text == "" || utf8.RuneCountInString(text) < 4 {
skipped = append(skipped, SkipReason{Text: text, Reason: "too_short"})
continue
}
if !questionLooksValid(text) {
skipped = append(skipped, SkipReason{Text: text, Reason: "invalid_text"})
continue
}
if _, ok := existing[key]; ok {
skipped = append(skipped, SkipReason{Text: text, Reason: "duplicate"})
continue
}
if _, ok := inBatch[key]; ok {
skipped = append(skipped, SkipReason{Text: text, Reason: "duplicate_in_batch"})
continue
}
if remaining <= 0 {
skipped = append(skipped, SkipReason{Text: text, Reason: "quota_exceeded"})
continue
}
classified := classifyQuestionText(text, brandCtx.BrandName, brandCtx.CompetitorNames)
accepted = append(accepted, acceptedQuestion{
Text: text,
Layer: classified.Layer,
Intent: classified.Intent,
Source: source,
})
inBatch[key] = struct{}{}
remaining--
}
if len(accepted) == 0 {
return nil, response.ErrBadRequest(40093, "no_valid_questions", skippedReasonDetail(skipped))
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(50010, "materialize_failed", "failed to begin transaction")
}
defer func() {
_ = tx.Rollback(ctx)
}()
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`, tenantQuestionQuotaLockKey(actor.TenantID), "questions"); err != nil {
return nil, response.ErrInternal(50010, "materialize_failed", "failed to lock question quota")
}
bucketID, err := s.ensureDefaultQuestionBucket(ctx, tx, actor.TenantID, brandID, brandCtx.BrandName)
if err != nil {
return nil, err
}
currentCount, err = countTenantQuestionsTx(ctx, tx, actor.TenantID)
if err != nil {
return nil, response.ErrInternal(50010, "materialize_failed", "failed to recount questions")
}
remaining = maxQuestions - currentCount
if remaining < 0 {
remaining = 0
}
if remaining < len(accepted) {
overflow := accepted[remaining:]
accepted = accepted[:remaining]
for _, q := range overflow {
skipped = append(skipped, SkipReason{Text: q.Text, Reason: "quota_exceeded_concurrent"})
}
}
if len(accepted) == 0 {
return nil, response.ErrBadRequest(40093, "no_valid_questions", skippedReasonDetail(skipped))
}
payload, err := json.Marshal(accepted)
if err != nil {
return nil, response.ErrInternal(50010, "materialize_failed", "failed to encode questions")
}
rows, err := tx.Query(ctx, `
INSERT INTO brand_questions (
tenant_id, brand_id, keyword_id, question_text,
layer, intent, source, status
)
SELECT $1, $2, $3, q.text, q.layer, q.intent, q.source, 'active'
FROM jsonb_to_recordset($4::jsonb)
AS q(text TEXT, layer TEXT, intent TEXT, source TEXT)
ON CONFLICT (tenant_id, brand_id, lower(btrim(question_text)))
WHERE deleted_at IS NULL
DO NOTHING
RETURNING question_text
`, actor.TenantID, brandID, bucketID, payload)
if err != nil {
return nil, response.ErrInternal(50010, "materialize_failed", "failed to insert questions")
}
inserted := map[string]struct{}{}
for rows.Next() {
var text string
if err := rows.Scan(&text); err != nil {
rows.Close()
return nil, response.ErrInternal(50010, "materialize_failed", "failed to scan inserted questions")
}
inserted[normalizeQuestionKey(text)] = struct{}{}
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, response.ErrInternal(50010, "materialize_failed", "failed to iterate inserted questions")
}
rows.Close()
for _, q := range accepted {
if _, ok := inserted[normalizeQuestionKey(q.Text)]; !ok {
skipped = append(skipped, SkipReason{Text: q.Text, Reason: "duplicate_concurrent"})
}
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50010, "materialize_failed", "failed to commit questions")
}
invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID)
s.logQuestionExpansionAudit(ctx, actor.UserID, actor.TenantID, brandID, source, len(inserted), skipped)
return &MaterializeQuestionsResult{
CreatedQuestions: len(inserted),
SkippedQuestions: skipped,
}, nil
}
func (s *QuestionExpansionService) loadQuestionBrandContext(ctx context.Context, tenantID, brandID int64) (*questionBrandContext, error) {
var brandName string
err := s.pool.QueryRow(ctx, `
SELECT name
FROM brands
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, brandID, tenantID).Scan(&brandName)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrNotFound(40420, "brand_not_found", "brand not found")
}
return nil, response.ErrInternal(50010, "query_failed", "failed to load brand")
}
rows, err := s.pool.Query(ctx, `
SELECT name
FROM competitors
WHERE brand_id = $1 AND tenant_id = $2 AND deleted_at IS NULL
ORDER BY id ASC
`, brandID, tenantID)
if err != nil {
return nil, response.ErrInternal(50010, "query_failed", "failed to load competitors")
}
defer rows.Close()
competitors := make([]string, 0)
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
}
competitors = append(competitors, name)
}
plan, err := s.brand.loadBrandLibraryPlan(ctx, tenantID)
if err != nil {
return nil, err
}
return &questionBrandContext{
TenantID: tenantID,
BrandID: brandID,
BrandName: brandName,
PlanCode: plan.PlanCode,
CompetitorNames: competitors,
}, nil
}
func (s *QuestionExpansionService) loadExistingQuestionKeys(ctx context.Context, tenantID, brandID int64) (map[string]struct{}, error) {
rows, err := s.pool.Query(ctx, `
SELECT lower(btrim(question_text))
FROM brand_questions
WHERE tenant_id = $1 AND brand_id = $2 AND deleted_at IS NULL
`, tenantID, brandID)
if err != nil {
return nil, response.ErrInternal(50010, "query_failed", "failed to load existing questions")
}
defer rows.Close()
result := map[string]struct{}{}
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
}
result[key] = struct{}{}
}
return result, nil
}
func (s *QuestionExpansionService) countTenantQuestions(ctx context.Context, tenantID int64) (int, error) {
return countTenantQuestionsTx(ctx, s.pool, tenantID)
}
func countTenantQuestionsTx(ctx context.Context, db interface {
QueryRow(context.Context, string, ...any) pgx.Row
}, tenantID int64) (int, error) {
var count int
err := db.QueryRow(ctx, `
SELECT COUNT(*)::INT
FROM brand_questions
WHERE tenant_id = $1 AND deleted_at IS NULL
`, tenantID).Scan(&count)
return count, err
}
func (s *QuestionExpansionService) ensureDefaultQuestionBucket(ctx context.Context, tx pgx.Tx, tenantID, brandID int64, brandName string) (int64, error) {
return ensureDefaultQuestionBucketTx(ctx, tx, tenantID, brandID, brandName)
}
func (s *QuestionExpansionService) logQuestionExpansionAudit(ctx context.Context, operatorID, tenantID, brandID int64, source string, created int, skipped []SkipReason) {
if s == nil || s.brand == nil || s.brand.auditLogs == nil {
return
}
afterJSON, _ := json.Marshal(map[string]interface{}{
"brand_id": brandID,
"source": source,
"created_questions": created,
"skipped_count": len(skipped),
})
result := "success"
resourceType := "brand"
requestID := middleware.RequestIDFromContext(ctx)
s.brand.auditLogs.Log(auditlog.Entry{
OperatorID: operatorID,
TenantID: &tenantID,
Module: "brand",
Action: "question_expansion.materialize",
ResourceType: &resourceType,
ResourceID: &brandID,
RequestID: nilIfEmptyString(requestID),
AfterJSON: afterJSON,
Result: &result,
})
}
func buildCandidate(text, source string, missingSuffix bool, existing map[string]struct{}, brandCtx *questionBrandContext) QuestionCandidate {
classified := classifyQuestionText(text, brandCtx.BrandName, brandCtx.CompetitorNames)
key := normalizeQuestionKey(text)
_, duplicate := existing[key]
tooShort := utf8.RuneCountInString(classified.Text) < 4
invalid := !questionLooksValid(classified.Text)
return QuestionCandidate{
Text: classified.Text,
Layer: classified.Layer,
Intent: classified.Intent,
Source: source,
MissingSuffix: missingSuffix,
TooShort: tooShort,
Duplicate: duplicate,
SuggestSkip: tooShort || invalid || duplicate,
}
}
func normalizeQuestionParts(values []string) []string {
result := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result = append(result, trimmed)
}
return result
}
func optionalQuestionParts(values []string) []string {
parts := normalizeQuestionParts(values)
if len(parts) == 0 {
return []string{""}
}
return parts
}
func nonEmptyParts(values ...string) []string {
result := make([]string, 0, len(values))
for _, value := range values {
if strings.TrimSpace(value) != "" {
result = append(result, strings.TrimSpace(value))
}
}
return result
}
func questionDistillCacheKey(tenantID, brandID int64, promptVersion, brandName string, competitors []string, seedTopic string) 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"`
Competitors []string `json:"competitors"`
SeedTopic string `json:"seed_topic"`
}{
TenantID: tenantID,
BrandID: brandID,
PromptVersion: promptVersion,
SchemaVersion: "question_distill_schema_v1",
BrandName: strings.TrimSpace(brandName),
Competitors: competitors,
SeedTopic: strings.TrimSpace(seedTopic),
})
sum := sha1.Sum(raw)
return "question_distill:" + hex.EncodeToString(sum[:])
}
func skippedReasonDetail(skipped []SkipReason) string {
if len(skipped) == 0 {
return "no valid questions"
}
raw, err := json.Marshal(skipped)
if err != nil {
return "no valid questions"
}
return string(raw)
}
func isUniqueQuestionConstraintError(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == "uk_brand_question_text_active"
}
@@ -0,0 +1,142 @@
package app
import (
"strings"
"unicode"
"unicode/utf8"
)
const (
QuestionLayerL1 = "L1"
QuestionLayerL2 = "L2"
QuestionLayerL3 = "L3"
QuestionLayerL4 = "L4"
QuestionLayerL5 = "L5"
QuestionIntentInformational = "informational"
QuestionIntentEvaluative = "evaluative"
QuestionIntentDecisional = "decisional"
QuestionSourceManual = "manual"
QuestionSourceCombination = "combination"
QuestionSourceAIDistill = "ai_distill"
)
type ClassifiedQuestion struct {
Text string `json:"text"`
Layer string `json:"layer"`
Intent string `json:"intent"`
}
func normalizeQuestionText(text string) string {
return strings.TrimSpace(text)
}
func normalizeQuestionKey(text string) string {
return strings.ToLower(strings.TrimSpace(text))
}
func isValidQuestionLayer(value string) bool {
switch strings.TrimSpace(value) {
case QuestionLayerL1, QuestionLayerL2, QuestionLayerL3, QuestionLayerL4, QuestionLayerL5:
return true
default:
return false
}
}
func isValidQuestionIntent(value string) bool {
switch strings.TrimSpace(value) {
case QuestionIntentInformational, QuestionIntentEvaluative, QuestionIntentDecisional:
return true
default:
return false
}
}
func isValidExternalQuestionSource(value string) bool {
switch strings.TrimSpace(value) {
case QuestionSourceManual, QuestionSourceCombination, QuestionSourceAIDistill:
return true
default:
return false
}
}
func inferQuestionIntent(text string) string {
normalized := strings.ToLower(strings.TrimSpace(text))
if containsAny(normalized, []string{"哪家好", "哪个好", "怎么选", "推荐", "哪里买", "适合谁", "多少钱", "报价", "购买"}) {
return QuestionIntentDecisional
}
if containsAny(normalized, []string{"对比", "区别", "哪个更", "优劣", "测评", "排名", "值不值", "替代", "竞品"}) {
return QuestionIntentEvaluative
}
return QuestionIntentInformational
}
func inferQuestionLayer(text, brandName string, competitorNames []string) string {
normalized := strings.ToLower(strings.TrimSpace(text))
brandHits := 0
if containsFold(normalized, brandName) {
brandHits++
}
for _, competitor := range competitorNames {
if containsFold(normalized, competitor) {
brandHits++
}
}
if brandHits >= 2 || containsAny(normalized, []string{"对比", "区别", "哪个更", "优劣", "竞品", "替代"}) {
return QuestionLayerL5
}
if brandHits == 1 {
return QuestionLayerL1
}
if containsAny(normalized, []string{"北京", "上海", "广州", "深圳", "杭州", "成都", "合肥", "华东", "华南", "华北", "中小企业", "企业", "团队", "老板", "新手", "预算", "价格", "费用", "场景", "本地"}) {
return QuestionLayerL4
}
if containsAny(normalized, []string{"痛点", "问题", "踩坑", "解决", "失败", "难", "风险", "怎么办"}) {
return QuestionLayerL3
}
return QuestionLayerL2
}
func classifyQuestionText(text, brandName string, competitorNames []string) ClassifiedQuestion {
trimmed := normalizeQuestionText(text)
return ClassifiedQuestion{
Text: trimmed,
Layer: inferQuestionLayer(trimmed, brandName, competitorNames),
Intent: inferQuestionIntent(trimmed),
}
}
func questionLooksValid(text string) bool {
trimmed := normalizeQuestionText(text)
if utf8.RuneCountInString(trimmed) < 4 {
return false
}
hasLetterOrNumber := false
for _, r := range trimmed {
if unicode.IsLetter(r) || unicode.IsNumber(r) {
hasLetterOrNumber = true
break
}
}
if !hasLetterOrNumber {
return false
}
return containsAny(strings.ToLower(trimmed), []string{"?", "", "什么", "如何", "怎么", "哪", "为什么", "是否", "能不能", "适合", "区别", "对比", "推荐"})
}
func containsAny(value string, needles []string) bool {
for _, needle := range needles {
if strings.Contains(value, strings.ToLower(strings.TrimSpace(needle))) {
return true
}
}
return false
}
func containsFold(value, needle string) bool {
needle = strings.ToLower(strings.TrimSpace(needle))
return needle != "" && strings.Contains(value, needle)
}
@@ -0,0 +1,102 @@
package transport
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/bootstrap"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/app"
)
type QuestionExpansionHandler struct {
svc *app.QuestionExpansionService
}
func NewQuestionExpansionHandler(a *bootstrap.App) *QuestionExpansionHandler {
return &QuestionExpansionHandler{svc: a.QuestionExpansion}
}
func (h *QuestionExpansionHandler) CombinationPreview(c *gin.Context) {
brandID, ok := parseBrandIDParam(c)
if !ok {
return
}
var req app.QuestionCombinationRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.GenerateByCombination(c.Request.Context(), brandID, req)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *QuestionExpansionHandler) AIDistill(c *gin.Context) {
brandID, ok := parseBrandIDParam(c)
if !ok {
return
}
var req app.QuestionDistillRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.GenerateByAIDistill(c.Request.Context(), brandID, req)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *QuestionExpansionHandler) ClassifyMetadata(c *gin.Context) {
brandID, ok := parseBrandIDParam(c)
if !ok {
return
}
var req struct {
Texts []string `json:"texts" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.ClassifyMetadata(c.Request.Context(), brandID, req.Texts)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *QuestionExpansionHandler) Materialize(c *gin.Context) {
brandID, ok := parseBrandIDParam(c)
if !ok {
return
}
var req app.MaterializeQuestionsRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.MaterializeQuestions(c.Request.Context(), brandID, req)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func parseBrandIDParam(c *gin.Context) (int64, bool) {
brandID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || brandID <= 0 {
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "brand id must be a number"))
return 0, false
}
return brandID, true
}
@@ -176,6 +176,7 @@ func RegisterRoutes(app *bootstrap.App) {
brands := tenantProtected.Group("/brands")
brandHandler := NewBrandHandler(app)
questionExpansionHandler := NewQuestionExpansionHandler(app)
brands.GET("", brandHandler.List)
brands.GET("/library-summary", brandHandler.Summary)
brands.POST("", brandHandler.Create)
@@ -188,6 +189,10 @@ func RegisterRoutes(app *bootstrap.App) {
brands.DELETE("/:id/keywords/:kid", brandHandler.DeleteKeyword)
brands.GET("/:id/questions", brandHandler.ListQuestions)
brands.POST("/:id/questions", brandHandler.CreateQuestion)
brands.POST("/:id/questions/combination-preview", questionExpansionHandler.CombinationPreview)
brands.POST("/:id/questions/ai-distill", questionExpansionHandler.AIDistill)
brands.POST("/:id/questions/classify-metadata", questionExpansionHandler.ClassifyMetadata)
brands.POST("/:id/questions/materialize", questionExpansionHandler.Materialize)
brands.PUT("/:id/questions/:qid", brandHandler.UpdateQuestion)
brands.DELETE("/:id/questions/:qid", brandHandler.DeleteQuestion)
brands.GET("/:id/competitors", brandHandler.ListCompetitors)