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
+102 -8
View File
@@ -212,6 +212,10 @@ const enUS = {
title: 'Brand Library',
description: 'Manage keywords, question sets, and competitors around each brand.',
},
brandQuestionCreate: {
title: 'New question',
description: 'Create brand questions with the expansion tool, AI expansion, or manual input.',
},
tracking: {
title: 'Data Details',
description:
@@ -1231,15 +1235,19 @@ const enUS = {
},
brands: {
eyebrow: 'Brand Library',
title: 'Brand Library',
description: 'Manage keywords, question sets, and competitor assets around each brand.',
title: 'Brand Management',
description: 'Manage question sets and competitor assets around each brand.',
railTitle: 'Brands',
selectBrand: 'Pick a brand to continue managing keywords and question sets.',
selectBrand: 'Pick a brand to continue managing question sets.',
newBrand: 'New brand',
editBrand: 'Edit brand',
deleteBrand: 'Delete brand',
guide: {
title: 'Brand management guide',
body: 'Manage brands, question sets, and competitor libraries for AI platform monitoring and GEO analysis.',
},
tabs: {
keywords: 'Keywords',
questions: 'Question set',
competitors: 'Competitors',
},
quota: {
@@ -1249,12 +1257,13 @@ const enUS = {
brandsHint: 'Free defaults to 1 and paid defaults to 2.',
keywords: 'Total keywords',
keywordsHint: 'The total number of keywords this account can bind.',
questions: 'Questions per keyword',
questionsHint: 'The maximum number of questions allowed under one keyword.',
questions: 'Account question quota',
questionsHint: 'All brands share this account question quota.',
},
meta: {
keywordCount: '{count} keywords',
questionCount: '{count} questions',
competitorCount: '{count} competitors',
brandKeywordCount: '{count} keywords under this brand',
globalKeywordUsage: 'Total keywords used {used} / {total}',
keywordQuestionUsage: 'Questions {used} / {total}',
@@ -1266,6 +1275,7 @@ const enUS = {
versions: 'Version history',
},
actions: {
addBrand: 'Add brand',
newKeyword: 'New keyword',
newQuestion: 'New question',
newCompetitor: 'New competitor',
@@ -1273,8 +1283,10 @@ const enUS = {
},
empty: {
brands: 'No brand data yet. Create your first brand to start.',
questions: 'No questions under this keyword yet.',
questions: 'No questions under this brand yet.',
competitors: 'No competitors under this brand yet.',
brandDescription: 'No brand description yet',
competitorDescription: 'No competitor description yet',
},
form: {
brandName: 'Brand name',
@@ -1305,7 +1317,89 @@ const enUS = {
chooseKeyword: 'Please choose a keyword first.',
brandLimitReached: 'Your current plan allows up to {limit} brand companies.',
keywordLimitReached: 'This account allows up to {limit} keywords.',
questionLimitReached: 'This keyword allows up to {limit} questions.',
questionLimitReached: 'This account allows up to {limit} questions.',
},
questions: {
create: 'New question',
createSingle: 'Add single question',
expand: 'Bulk expand questions',
usage: 'Questions {used} / {total}',
remaining: '{count} remaining',
generate: 'Generate candidates',
emptyCandidates: 'No candidates yet. Generate or paste questions above.',
table: {
question: 'Question set',
},
modal: {
title: 'New question',
currentBrand: 'Current brand',
methods: {
single: 'Single',
combination: 'Expansion tool',
aiDistill: 'AI expansion',
manualBatch: 'Manual batch',
},
preview: 'Candidate preview',
summary:
'{total} candidates · {selected} selected · {tooShort} too short · {duplicate} duplicates',
save: 'Save questions ({count})',
},
combination: {
region: '1. Region',
prefix: '2. Prefix',
core: '3. Core (required)',
industry: '4. Industry (required)',
suffix: '5. Suffix',
defaultWord: 'Default',
estimate: '{count} candidates estimated',
coreRequired: 'Core is required',
industryRequired: 'Industry is required',
},
ai: {
note: 'AI expansion consumes 1 AI point. Failed generations are refunded automatically.',
seedTopic: 'Seed topic',
generate: 'Generate with AI',
},
batch: {
label: 'One question per line',
},
badges: {
duplicate: 'Duplicate',
tooShort: 'Too short',
},
messages: {
partialSaved: 'Saved {created}; skipped {skipped}.',
limitReached: 'This account allows up to {limit} questions shared across all brands.',
truncated: 'Candidates exceeded the limit and were truncated to 500.',
cacheHit: 'Used the most recent AI expansion result.',
aiCharged: 'This AI expansion consumed {count} AI point.',
},
errors: {
emptyInput: 'Enter question content first.',
noSelection: 'Select at least one candidate.',
noValidQuestions: 'No valid questions can be saved. Adjust candidates and try again.',
invalidCandidate:
'This candidate cannot be selected until it is no longer too short, duplicated, or invalid.',
},
page: {
title: 'New question',
subtitle: 'Generate candidates, review them, then save them to this brand question set.',
steps: {
configure: 'Configure',
preview: 'Review candidates',
},
methodTitle: 'Choose a creation method',
methodHint:
'Candidates remain temporary until saved, so you can generate, edit, and select first.',
methods: {
combination: 'Expand candidates from the column-based word tool.',
ai: 'Let AI generate candidates around a seed topic.',
batch: 'Paste questions from a sheet or document.',
},
expansionHint:
'One word per line. Empty optional columns are skipped; core and industry are required.',
batchHint: 'One question per line. You can edit and select each one before saving.',
},
},
},
custom: {
+99 -8
View File
@@ -204,6 +204,10 @@ const zhCN = {
title: "品牌词库",
description: "围绕品牌维护关键词、问题集和竞品信息。",
},
brandQuestionCreate: {
title: "新建问题",
description: "用拓词工具、AI 扩展或手动录入创建品牌问题集。",
},
tracking: {
title: "数据详情",
description: "围绕插件采样快照查看品牌表现、平台矩阵、高频问题和引用归因。",
@@ -1163,15 +1167,20 @@ const zhCN = {
},
},
brands: {
title: "品牌词库",
description: "通过品牌名称和品牌相关信息维护关键词、问题集和竞品资产。",
eyebrow: "Brand Management",
title: "品牌管理",
description: "通过品牌名称和品牌相关信息维护问题集和竞品资产。",
railTitle: "品牌库",
selectBrand: "选择品牌后即可继续维护关键词和问题集。",
selectBrand: "选择品牌后即可继续维护问题集。",
newBrand: "新建品牌",
editBrand: "编辑品牌",
deleteBrand: "删除品牌",
guide: {
title: "品牌管理说明",
body: "管理品牌、问题集和竞品库,用于后续 AI 平台监测与 GEO 分析。",
},
tabs: {
keywords: "关键词",
questions: "问题集",
competitors: "竞品库",
},
quota: {
@@ -1181,12 +1190,13 @@ const zhCN = {
brandsHint: "免费版默认 1 个,付费版默认 2 个。",
keywords: "关键词总量",
keywordsHint: "当前用户可绑定的关键词总数。",
questions: "每个关键词问题数",
questionsHint: "单个关键词下最多可维护的问题。",
questions: "账号问题额度",
questionsHint: "所有品牌共用当前账号的问题额度。",
},
meta: {
keywordCount: "{count} 个关键词",
questionCount: "{count} 个问题",
competitorCount: "{count} 个竞品",
brandKeywordCount: "当前品牌已绑定 {count} 个关键词",
globalKeywordUsage: "总关键词已用 {used} / {total}",
keywordQuestionUsage: "问题 {used} / {total}",
@@ -1198,6 +1208,7 @@ const zhCN = {
versions: "版本记录",
},
actions: {
addBrand: "添加品牌",
newKeyword: "新建关键词",
newQuestion: "新建问题",
newCompetitor: "新建竞品",
@@ -1205,8 +1216,10 @@ const zhCN = {
},
empty: {
brands: "还没有品牌数据,先创建一个品牌。",
questions: "当前关键词下暂无问题。",
questions: "当前品牌下暂无问题。",
competitors: "当前品牌下暂无竞品数据。",
brandDescription: "暂无品牌描述",
competitorDescription: "暂无竞品描述",
},
form: {
brandName: "品牌名称",
@@ -1237,7 +1250,85 @@ const zhCN = {
chooseKeyword: "请先选择关键词",
brandLimitReached: "当前套餐最多可绑定 {limit} 个品牌公司",
keywordLimitReached: "当前账号最多可绑定 {limit} 个关键词",
questionLimitReached: "当前关键词下最多可维护 {limit} 个问题",
questionLimitReached: "当前账号最多可维护 {limit} 个问题",
},
questions: {
create: "新建问题",
createSingle: "新增单条问题",
expand: "批量扩展问题",
usage: "问题 {used} / {total}",
remaining: "还可保存 {count} 条",
generate: "生成候选",
emptyCandidates: "还没有候选,先从上方生成或粘贴问题。",
table: {
question: "问题集",
},
modal: {
title: "新建问题",
currentBrand: "当前品牌",
methods: {
single: "单条新增",
combination: "拓词工具",
aiDistill: "AI 扩展",
manualBatch: "手动批量",
},
preview: "候选预览",
summary: "共 {total} 条候选 · 已选择 {selected} 条 · 过短 {tooShort} 条 · 重复 {duplicate} 条",
save: "保存问题 ({count})",
},
combination: {
region: "1.地域词",
prefix: "2.前缀词",
core: "3.核心词(必填)",
industry: "4.行业词(必填)",
suffix: "5.后缀词",
defaultWord: "默认词",
estimate: "预计生成 {count} 条候选",
coreRequired: "核心不能为空",
industryRequired: "行业不能为空",
},
ai: {
note: "AI 扩展会消耗 1 个 AI 点;生成失败时系统会自动退还。",
seedTopic: "扩展主题",
generate: "AI 生成候选",
},
batch: {
label: "每行一个问题",
},
badges: {
duplicate: "重复",
tooShort: "过短",
},
messages: {
partialSaved: "已保存 {created} 条,跳过 {skipped} 条",
limitReached: "当前账号最多可维护 {limit} 个问题,所有品牌共用该额度",
truncated: "候选超过上限,已截断为 500 条。",
cacheHit: "已使用最近一次 AI 扩展结果。",
aiCharged: "本次 AI 扩展已消耗 {count} 个 AI 点。",
},
errors: {
emptyInput: "请先输入问题内容",
noSelection: "请至少选择 1 条候选",
noValidQuestions: "没有可保存的问题,请调整候选后重试",
invalidCandidate: "该候选暂不可选择,请先处理过短、重复或非问题文本",
},
page: {
title: "新建问题",
subtitle: "按向导生成候选,确认后一次性保存到当前品牌的问题集。",
steps: {
configure: "配置生成方式",
preview: "确认候选",
},
methodTitle: "选择创建方式",
methodHint: "候选保存前都是临时内容,你可以先生成、再筛选和编辑。",
methods: {
combination: "按图示词列批量拓展问题候选。",
ai: "让 AI 围绕主题生成问题候选。",
batch: "从表格或文档中批量粘贴问题。",
},
expansionHint: "每行一个词;空列会按默认词跳过,核心词和行业词必填。",
batchHint: "每行一个问题,保存前可以逐条编辑和勾选。",
},
},
},
custom: {
+31
View File
@@ -72,6 +72,8 @@ import type {
ListDesktopPublishTasksParams,
LoginRequest,
LoginResponse,
MaterializeQuestionsRequest,
MaterializeQuestionsResult,
MediaPlatform,
MonitoringCitationSummaryResponse,
MonitoringCollectNowResponse,
@@ -89,6 +91,9 @@ import type {
PublishKolPromptRequest,
PublishRecord,
Question,
QuestionCandidateResult,
QuestionCombinationRequest,
QuestionDistillRequest,
QuestionRequest,
QuotaSummary,
RecentArticle,
@@ -122,6 +127,8 @@ import type {
UpdateQuestionRequest,
UserInfo,
WorkspaceOverview,
ClassifyQuestionsRequest,
ClassifiedQuestion,
} from '@geo/shared-types'
import {
@@ -1036,6 +1043,30 @@ export const brandsApi = {
params: keywordId ? { keyword_id: keywordId } : undefined,
})
},
previewQuestionCombination(brandId: number, payload: QuestionCombinationRequest) {
return apiClient.post<QuestionCandidateResult, QuestionCombinationRequest>(
`/api/tenant/brands/${brandId}/questions/combination-preview`,
payload,
)
},
distillQuestions(brandId: number, payload: QuestionDistillRequest) {
return apiClient.post<QuestionCandidateResult, QuestionDistillRequest>(
`/api/tenant/brands/${brandId}/questions/ai-distill`,
payload,
)
},
classifyQuestions(brandId: number, payload: ClassifyQuestionsRequest) {
return apiClient.post<ClassifiedQuestion[], ClassifyQuestionsRequest>(
`/api/tenant/brands/${brandId}/questions/classify-metadata`,
payload,
)
},
materializeQuestions(brandId: number, payload: MaterializeQuestionsRequest) {
return apiClient.post<MaterializeQuestionsResult, MaterializeQuestionsRequest>(
`/api/tenant/brands/${brandId}/questions/materialize`,
payload,
)
},
createQuestion(brandId: number, payload: QuestionRequest) {
return apiClient.post<Question, QuestionRequest>(
`/api/tenant/brands/${brandId}/questions`,
+7 -1
View File
@@ -67,7 +67,13 @@ const errorMessageMap: Record<string, string> = {
brand_limit_reached: '品牌公司数量已达当前套餐上限',
keyword_exists: '关键词已存在',
keyword_limit_reached: '关键词数量已达当前套餐上限',
question_limit_reached: '当前关键词下的问题数量已达上限',
question_exists: '该品牌下已存在相同问题',
no_valid_questions: '没有可保存的问题,请调整候选后重试',
invalid_enum: '请求枚举值不合法',
llm_timeout: 'AI 扩展超时,AI 点已退还',
llm_invalid_output: 'AI 输出无法解析,AI 点已退还',
materialize_failed: '保存问题失败,请稍后重试',
question_limit_reached: '当前账号问题数量已达上限',
knowledge_text_name_too_long: '文本名称不能超过 20 个字',
brand_not_found: '品牌不存在或已删除',
keyword_not_found: '关键词不存在或已删除',
+10
View File
@@ -145,6 +145,16 @@ const router = createRouter({
navKey: '/brands',
},
},
{
path: 'brands/:brandId/questions/create',
name: 'brand-question-create',
component: () => import('@/views/BrandQuestionCreateView.vue'),
meta: {
titleKey: 'route.brandQuestionCreate.title',
descriptionKey: 'route.brandQuestionCreate.description',
navKey: '/brands',
},
},
{
path: 'tracking',
name: 'tracking',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5
View File
@@ -132,6 +132,11 @@ membership:
brand_library:
free_brand_limit: 1
paid_brand_limit: 2
question_limits_by_plan:
default: 25
free: 5
plus: 25
pro: 50
max_keywords: 5
max_questions_per_keyword: 5
+5
View File
@@ -88,6 +88,11 @@ membership:
brand_library:
free_brand_limit: 1
paid_brand_limit: 2
question_limits_by_plan:
default: 25
free: 5
plus: 25
pro: 50
max_keywords: 5
max_questions_per_keyword: 5
File diff suppressed because it is too large Load Diff
+69
View File
@@ -1214,6 +1214,7 @@ export interface Brand {
status: string
keyword_count: number
question_count: number
competitor_count?: number
created_at: string
updated_at?: string
}
@@ -1257,7 +1258,75 @@ export interface BrandLibrarySummary {
max_keywords: number
used_keywords: number
remaining_keywords: number
max_questions: number
used_questions: number
remaining_questions: number
max_questions_per_keyword: number
max_questions_per_brand: number
}
export type QuestionSource = 'manual' | 'combination' | 'ai_distill'
export type QuestionIntent = 'informational' | 'evaluative' | 'decisional'
export type QuestionLayer = 'L1' | 'L2' | 'L3' | 'L4' | 'L5'
export interface QuestionCombinationRequest {
region?: string[]
prefix?: string[]
core: string[]
industry: string[]
suffix?: string[]
max_items?: number
}
export interface QuestionDistillRequest {
seed_topic: string
}
export interface QuestionCandidate {
text: string
layer: QuestionLayer
intent: QuestionIntent
source: QuestionSource
missing_suffix?: boolean
too_short?: boolean
duplicate?: boolean
suggest_skip?: boolean
}
export interface QuestionCandidateResult {
candidates: QuestionCandidate[]
ai_points_charged?: number
cache_hit?: boolean
truncated?: boolean
}
export interface ClassifiedQuestion {
text: string
layer: QuestionLayer
intent: QuestionIntent
}
export interface MaterializeQuestion {
text: string
}
export interface MaterializeQuestionsRequest {
source: QuestionSource
questions: MaterializeQuestion[]
}
export interface SkipReason {
text: string
reason: string
}
export interface MaterializeQuestionsResult {
created_questions: number
skipped_questions: SkipReason[]
}
export interface ClassifyQuestionsRequest {
texts: string[]
}
export interface CompetitorRequest {
+5
View File
@@ -115,6 +115,11 @@ membership:
brand_library:
free_brand_limit: 1
paid_brand_limit: 2
question_limits_by_plan:
default: 25
free: 5
plus: 25
pro: 50
max_keywords: 5
max_questions_per_keyword: 5
+5
View File
@@ -132,6 +132,11 @@ membership:
brand_library:
free_brand_limit: 1
paid_brand_limit: 2
question_limits_by_plan:
default: 25
free: 5
plus: 25
pro: 50
max_keywords: 5
max_questions_per_keyword: 5
+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,
+38
View File
@@ -241,6 +241,7 @@ type BrandLibraryConfig struct {
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 {
@@ -177,6 +177,10 @@ var routeDocs = map[string]routeDoc{
"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": {"删除监控问题", "删除某条监控问题。"},
@@ -23,6 +23,7 @@ const (
AIUsageTypeTemplateOutlineGenerate = "template_outline_generate"
AIUsageTypeKolPromptGenerate = "kol_prompt_generate"
AIUsageTypeKolPromptOptimize = "kol_prompt_optimize"
AIUsageTypeQuestionDistill = "question_distill"
aiPointsQuotaType = "ai_points"
)
+197 -50
View File
@@ -75,6 +75,7 @@ type BrandResponse struct {
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"`
}
@@ -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"
@@ -166,6 +186,7 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp
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
@@ -727,7 +803,11 @@ 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
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")
@@ -810,12 +890,12 @@ func (s *BrandService) loadBrandCompetitors(ctx context.Context, tenantID, brand
type brandLibraryPlan struct {
PlanCode string
PlanName string
MaxBrands int
}
type brandLibraryUsage struct {
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"),
}
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")
}
@@ -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)
@@ -0,0 +1,19 @@
DROP INDEX IF EXISTS uk_brand_question_text_active;
DROP INDEX IF EXISTS idx_brand_questions_intent;
ALTER TABLE brand_questions
DROP CONSTRAINT IF EXISTS ck_brand_questions_layer,
DROP CONSTRAINT IF EXISTS ck_brand_questions_intent,
DROP CONSTRAINT IF EXISTS ck_brand_questions_source,
DROP COLUMN IF EXISTS source,
DROP COLUMN IF EXISTS intent,
DROP COLUMN IF EXISTS layer;
DROP INDEX IF EXISTS idx_brand_keywords_internal_source;
ALTER TABLE brand_keywords
DROP CONSTRAINT IF EXISTS ck_brand_keywords_layer,
DROP CONSTRAINT IF EXISTS ck_brand_keywords_source,
DROP COLUMN IF EXISTS source,
DROP COLUMN IF EXISTS seed_word,
DROP COLUMN IF EXISTS layer;
@@ -0,0 +1,44 @@
ALTER TABLE brand_keywords
ADD COLUMN IF NOT EXISTS layer VARCHAR(8) NOT NULL DEFAULT 'L2',
ADD COLUMN IF NOT EXISTS seed_word VARCHAR(200),
ADD COLUMN IF NOT EXISTS source VARCHAR(32) NOT NULL DEFAULT 'manual';
ALTER TABLE brand_keywords
DROP CONSTRAINT IF EXISTS ck_brand_keywords_layer,
DROP CONSTRAINT IF EXISTS ck_brand_keywords_source;
ALTER TABLE brand_keywords
ADD CONSTRAINT ck_brand_keywords_layer
CHECK (layer IN ('L1','L2','L3','L4','L5')),
ADD CONSTRAINT ck_brand_keywords_source
CHECK (source IN ('manual','combination','ai_distill','auto'));
CREATE INDEX IF NOT EXISTS idx_brand_keywords_internal_source
ON brand_keywords (tenant_id, brand_id, source)
WHERE deleted_at IS NULL;
ALTER TABLE brand_questions
ADD COLUMN IF NOT EXISTS layer VARCHAR(8) NOT NULL DEFAULT 'L2',
ADD COLUMN IF NOT EXISTS intent VARCHAR(16) NOT NULL DEFAULT 'informational',
ADD COLUMN IF NOT EXISTS source VARCHAR(32) NOT NULL DEFAULT 'manual';
ALTER TABLE brand_questions
DROP CONSTRAINT IF EXISTS ck_brand_questions_layer,
DROP CONSTRAINT IF EXISTS ck_brand_questions_intent,
DROP CONSTRAINT IF EXISTS ck_brand_questions_source;
ALTER TABLE brand_questions
ADD CONSTRAINT ck_brand_questions_layer
CHECK (layer IN ('L1','L2','L3','L4','L5')),
ADD CONSTRAINT ck_brand_questions_intent
CHECK (intent IN ('informational','evaluative','decisional')),
ADD CONSTRAINT ck_brand_questions_source
CHECK (source IN ('manual','combination','ai_distill'));
CREATE INDEX IF NOT EXISTS idx_brand_questions_intent
ON brand_questions (tenant_id, brand_id, intent)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uk_brand_question_text_active
ON brand_questions (tenant_id, brand_id, lower(btrim(question_text)))
WHERE deleted_at IS NULL;