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:
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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: '关键词不存在或已删除',
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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, "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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user