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: {