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:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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, "aPolicyJSON)
|
||||
`, 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, "aPolicy)
|
||||
}
|
||||
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(`你是一个 GEO(Generative 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)
|
||||
|
||||
Reference in New Issue
Block a user