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
Reference in New Issue
Block a user