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