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',
|
title: 'Brand Library',
|
||||||
description: 'Manage keywords, question sets, and competitors around each brand.',
|
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: {
|
tracking: {
|
||||||
title: 'Data Details',
|
title: 'Data Details',
|
||||||
description:
|
description:
|
||||||
@@ -1231,15 +1235,19 @@ const enUS = {
|
|||||||
},
|
},
|
||||||
brands: {
|
brands: {
|
||||||
eyebrow: 'Brand Library',
|
eyebrow: 'Brand Library',
|
||||||
title: 'Brand Library',
|
title: 'Brand Management',
|
||||||
description: 'Manage keywords, question sets, and competitor assets around each brand.',
|
description: 'Manage question sets and competitor assets around each brand.',
|
||||||
railTitle: 'Brands',
|
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',
|
newBrand: 'New brand',
|
||||||
editBrand: 'Edit brand',
|
editBrand: 'Edit brand',
|
||||||
deleteBrand: 'Delete 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: {
|
tabs: {
|
||||||
keywords: 'Keywords',
|
questions: 'Question set',
|
||||||
competitors: 'Competitors',
|
competitors: 'Competitors',
|
||||||
},
|
},
|
||||||
quota: {
|
quota: {
|
||||||
@@ -1249,12 +1257,13 @@ const enUS = {
|
|||||||
brandsHint: 'Free defaults to 1 and paid defaults to 2.',
|
brandsHint: 'Free defaults to 1 and paid defaults to 2.',
|
||||||
keywords: 'Total keywords',
|
keywords: 'Total keywords',
|
||||||
keywordsHint: 'The total number of keywords this account can bind.',
|
keywordsHint: 'The total number of keywords this account can bind.',
|
||||||
questions: 'Questions per keyword',
|
questions: 'Account question quota',
|
||||||
questionsHint: 'The maximum number of questions allowed under one keyword.',
|
questionsHint: 'All brands share this account question quota.',
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
keywordCount: '{count} keywords',
|
keywordCount: '{count} keywords',
|
||||||
questionCount: '{count} questions',
|
questionCount: '{count} questions',
|
||||||
|
competitorCount: '{count} competitors',
|
||||||
brandKeywordCount: '{count} keywords under this brand',
|
brandKeywordCount: '{count} keywords under this brand',
|
||||||
globalKeywordUsage: 'Total keywords used {used} / {total}',
|
globalKeywordUsage: 'Total keywords used {used} / {total}',
|
||||||
keywordQuestionUsage: 'Questions {used} / {total}',
|
keywordQuestionUsage: 'Questions {used} / {total}',
|
||||||
@@ -1266,6 +1275,7 @@ const enUS = {
|
|||||||
versions: 'Version history',
|
versions: 'Version history',
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
addBrand: 'Add brand',
|
||||||
newKeyword: 'New keyword',
|
newKeyword: 'New keyword',
|
||||||
newQuestion: 'New question',
|
newQuestion: 'New question',
|
||||||
newCompetitor: 'New competitor',
|
newCompetitor: 'New competitor',
|
||||||
@@ -1273,8 +1283,10 @@ const enUS = {
|
|||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
brands: 'No brand data yet. Create your first brand to start.',
|
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.',
|
competitors: 'No competitors under this brand yet.',
|
||||||
|
brandDescription: 'No brand description yet',
|
||||||
|
competitorDescription: 'No competitor description yet',
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
brandName: 'Brand name',
|
brandName: 'Brand name',
|
||||||
@@ -1305,7 +1317,89 @@ const enUS = {
|
|||||||
chooseKeyword: 'Please choose a keyword first.',
|
chooseKeyword: 'Please choose a keyword first.',
|
||||||
brandLimitReached: 'Your current plan allows up to {limit} brand companies.',
|
brandLimitReached: 'Your current plan allows up to {limit} brand companies.',
|
||||||
keywordLimitReached: 'This account allows up to {limit} keywords.',
|
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: {
|
custom: {
|
||||||
|
|||||||
@@ -204,6 +204,10 @@ const zhCN = {
|
|||||||
title: "品牌词库",
|
title: "品牌词库",
|
||||||
description: "围绕品牌维护关键词、问题集和竞品信息。",
|
description: "围绕品牌维护关键词、问题集和竞品信息。",
|
||||||
},
|
},
|
||||||
|
brandQuestionCreate: {
|
||||||
|
title: "新建问题",
|
||||||
|
description: "用拓词工具、AI 扩展或手动录入创建品牌问题集。",
|
||||||
|
},
|
||||||
tracking: {
|
tracking: {
|
||||||
title: "数据详情",
|
title: "数据详情",
|
||||||
description: "围绕插件采样快照查看品牌表现、平台矩阵、高频问题和引用归因。",
|
description: "围绕插件采样快照查看品牌表现、平台矩阵、高频问题和引用归因。",
|
||||||
@@ -1163,15 +1167,20 @@ const zhCN = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
brands: {
|
brands: {
|
||||||
title: "品牌词库",
|
eyebrow: "Brand Management",
|
||||||
description: "通过品牌名称和品牌相关信息维护关键词、问题集和竞品资产。",
|
title: "品牌管理",
|
||||||
|
description: "通过品牌名称和品牌相关信息维护问题集和竞品资产。",
|
||||||
railTitle: "品牌库",
|
railTitle: "品牌库",
|
||||||
selectBrand: "选择品牌后即可继续维护关键词和问题集。",
|
selectBrand: "选择品牌后即可继续维护问题集。",
|
||||||
newBrand: "新建品牌",
|
newBrand: "新建品牌",
|
||||||
editBrand: "编辑品牌",
|
editBrand: "编辑品牌",
|
||||||
deleteBrand: "删除品牌",
|
deleteBrand: "删除品牌",
|
||||||
|
guide: {
|
||||||
|
title: "品牌管理说明",
|
||||||
|
body: "管理品牌、问题集和竞品库,用于后续 AI 平台监测与 GEO 分析。",
|
||||||
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
keywords: "关键词",
|
questions: "问题集",
|
||||||
competitors: "竞品库",
|
competitors: "竞品库",
|
||||||
},
|
},
|
||||||
quota: {
|
quota: {
|
||||||
@@ -1181,12 +1190,13 @@ const zhCN = {
|
|||||||
brandsHint: "免费版默认 1 个,付费版默认 2 个。",
|
brandsHint: "免费版默认 1 个,付费版默认 2 个。",
|
||||||
keywords: "关键词总量",
|
keywords: "关键词总量",
|
||||||
keywordsHint: "当前用户可绑定的关键词总数。",
|
keywordsHint: "当前用户可绑定的关键词总数。",
|
||||||
questions: "每个关键词问题数",
|
questions: "账号问题额度",
|
||||||
questionsHint: "单个关键词下最多可维护的问题数。",
|
questionsHint: "所有品牌共用当前账号的问题额度。",
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
keywordCount: "{count} 个关键词",
|
keywordCount: "{count} 个关键词",
|
||||||
questionCount: "{count} 个问题",
|
questionCount: "{count} 个问题",
|
||||||
|
competitorCount: "{count} 个竞品",
|
||||||
brandKeywordCount: "当前品牌已绑定 {count} 个关键词",
|
brandKeywordCount: "当前品牌已绑定 {count} 个关键词",
|
||||||
globalKeywordUsage: "总关键词已用 {used} / {total}",
|
globalKeywordUsage: "总关键词已用 {used} / {total}",
|
||||||
keywordQuestionUsage: "问题 {used} / {total}",
|
keywordQuestionUsage: "问题 {used} / {total}",
|
||||||
@@ -1198,6 +1208,7 @@ const zhCN = {
|
|||||||
versions: "版本记录",
|
versions: "版本记录",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
addBrand: "添加品牌",
|
||||||
newKeyword: "新建关键词",
|
newKeyword: "新建关键词",
|
||||||
newQuestion: "新建问题",
|
newQuestion: "新建问题",
|
||||||
newCompetitor: "新建竞品",
|
newCompetitor: "新建竞品",
|
||||||
@@ -1205,8 +1216,10 @@ const zhCN = {
|
|||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
brands: "还没有品牌数据,先创建一个品牌。",
|
brands: "还没有品牌数据,先创建一个品牌。",
|
||||||
questions: "当前关键词下暂无问题集。",
|
questions: "当前品牌下暂无问题。",
|
||||||
competitors: "当前品牌下暂无竞品数据。",
|
competitors: "当前品牌下暂无竞品数据。",
|
||||||
|
brandDescription: "暂无品牌描述",
|
||||||
|
competitorDescription: "暂无竞品描述",
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
brandName: "品牌名称",
|
brandName: "品牌名称",
|
||||||
@@ -1237,7 +1250,85 @@ const zhCN = {
|
|||||||
chooseKeyword: "请先选择关键词",
|
chooseKeyword: "请先选择关键词",
|
||||||
brandLimitReached: "当前套餐最多可绑定 {limit} 个品牌公司",
|
brandLimitReached: "当前套餐最多可绑定 {limit} 个品牌公司",
|
||||||
keywordLimitReached: "当前账号最多可绑定 {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: {
|
custom: {
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ import type {
|
|||||||
ListDesktopPublishTasksParams,
|
ListDesktopPublishTasksParams,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
|
MaterializeQuestionsRequest,
|
||||||
|
MaterializeQuestionsResult,
|
||||||
MediaPlatform,
|
MediaPlatform,
|
||||||
MonitoringCitationSummaryResponse,
|
MonitoringCitationSummaryResponse,
|
||||||
MonitoringCollectNowResponse,
|
MonitoringCollectNowResponse,
|
||||||
@@ -89,6 +91,9 @@ import type {
|
|||||||
PublishKolPromptRequest,
|
PublishKolPromptRequest,
|
||||||
PublishRecord,
|
PublishRecord,
|
||||||
Question,
|
Question,
|
||||||
|
QuestionCandidateResult,
|
||||||
|
QuestionCombinationRequest,
|
||||||
|
QuestionDistillRequest,
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
QuotaSummary,
|
QuotaSummary,
|
||||||
RecentArticle,
|
RecentArticle,
|
||||||
@@ -122,6 +127,8 @@ import type {
|
|||||||
UpdateQuestionRequest,
|
UpdateQuestionRequest,
|
||||||
UserInfo,
|
UserInfo,
|
||||||
WorkspaceOverview,
|
WorkspaceOverview,
|
||||||
|
ClassifyQuestionsRequest,
|
||||||
|
ClassifiedQuestion,
|
||||||
} from '@geo/shared-types'
|
} from '@geo/shared-types'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -1036,6 +1043,30 @@ export const brandsApi = {
|
|||||||
params: keywordId ? { keyword_id: keywordId } : undefined,
|
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) {
|
createQuestion(brandId: number, payload: QuestionRequest) {
|
||||||
return apiClient.post<Question, QuestionRequest>(
|
return apiClient.post<Question, QuestionRequest>(
|
||||||
`/api/tenant/brands/${brandId}/questions`,
|
`/api/tenant/brands/${brandId}/questions`,
|
||||||
|
|||||||
@@ -67,7 +67,13 @@ const errorMessageMap: Record<string, string> = {
|
|||||||
brand_limit_reached: '品牌公司数量已达当前套餐上限',
|
brand_limit_reached: '品牌公司数量已达当前套餐上限',
|
||||||
keyword_exists: '关键词已存在',
|
keyword_exists: '关键词已存在',
|
||||||
keyword_limit_reached: '关键词数量已达当前套餐上限',
|
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 个字',
|
knowledge_text_name_too_long: '文本名称不能超过 20 个字',
|
||||||
brand_not_found: '品牌不存在或已删除',
|
brand_not_found: '品牌不存在或已删除',
|
||||||
keyword_not_found: '关键词不存在或已删除',
|
keyword_not_found: '关键词不存在或已删除',
|
||||||
|
|||||||
@@ -145,6 +145,16 @@ const router = createRouter({
|
|||||||
navKey: '/brands',
|
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',
|
path: 'tracking',
|
||||||
name: '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:
|
brand_library:
|
||||||
free_brand_limit: 1
|
free_brand_limit: 1
|
||||||
paid_brand_limit: 2
|
paid_brand_limit: 2
|
||||||
|
question_limits_by_plan:
|
||||||
|
default: 25
|
||||||
|
free: 5
|
||||||
|
plus: 25
|
||||||
|
pro: 50
|
||||||
max_keywords: 5
|
max_keywords: 5
|
||||||
max_questions_per_keyword: 5
|
max_questions_per_keyword: 5
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ membership:
|
|||||||
brand_library:
|
brand_library:
|
||||||
free_brand_limit: 1
|
free_brand_limit: 1
|
||||||
paid_brand_limit: 2
|
paid_brand_limit: 2
|
||||||
|
question_limits_by_plan:
|
||||||
|
default: 25
|
||||||
|
free: 5
|
||||||
|
plus: 25
|
||||||
|
pro: 50
|
||||||
max_keywords: 5
|
max_keywords: 5
|
||||||
max_questions_per_keyword: 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
|
status: string
|
||||||
keyword_count: number
|
keyword_count: number
|
||||||
question_count: number
|
question_count: number
|
||||||
|
competitor_count?: number
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
@@ -1257,7 +1258,75 @@ export interface BrandLibrarySummary {
|
|||||||
max_keywords: number
|
max_keywords: number
|
||||||
used_keywords: number
|
used_keywords: number
|
||||||
remaining_keywords: number
|
remaining_keywords: number
|
||||||
|
max_questions: number
|
||||||
|
used_questions: number
|
||||||
|
remaining_questions: number
|
||||||
max_questions_per_keyword: 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 {
|
export interface CompetitorRequest {
|
||||||
|
|||||||
@@ -115,6 +115,11 @@ membership:
|
|||||||
brand_library:
|
brand_library:
|
||||||
free_brand_limit: 1
|
free_brand_limit: 1
|
||||||
paid_brand_limit: 2
|
paid_brand_limit: 2
|
||||||
|
question_limits_by_plan:
|
||||||
|
default: 25
|
||||||
|
free: 5
|
||||||
|
plus: 25
|
||||||
|
pro: 50
|
||||||
max_keywords: 5
|
max_keywords: 5
|
||||||
max_questions_per_keyword: 5
|
max_questions_per_keyword: 5
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,11 @@ membership:
|
|||||||
brand_library:
|
brand_library:
|
||||||
free_brand_limit: 1
|
free_brand_limit: 1
|
||||||
paid_brand_limit: 2
|
paid_brand_limit: 2
|
||||||
|
question_limits_by_plan:
|
||||||
|
default: 25
|
||||||
|
free: 5
|
||||||
|
plus: 25
|
||||||
|
pro: 50
|
||||||
max_keywords: 5
|
max_keywords: 5
|
||||||
max_questions_per_keyword: 5
|
max_questions_per_keyword: 5
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ type App struct {
|
|||||||
DesktopDispatch *stream.DesktopDispatchHub
|
DesktopDispatch *stream.DesktopDispatchHub
|
||||||
Cache cache.Cache
|
Cache cache.Cache
|
||||||
BrandService *tenantapp.BrandService
|
BrandService *tenantapp.BrandService
|
||||||
|
QuestionExpansion *tenantapp.QuestionExpansionService
|
||||||
MonitoringService *tenantapp.MonitoringService
|
MonitoringService *tenantapp.MonitoringService
|
||||||
KolProfiles repository.KolProfileRepository
|
KolProfiles repository.KolProfileRepository
|
||||||
KolPackages repository.KolPackageRepository
|
KolPackages repository.KolPackageRepository
|
||||||
@@ -145,6 +146,7 @@ func New(configPath string) (*App, error) {
|
|||||||
DeleteScanCount: cfg.Cache.DeleteScanCount,
|
DeleteScanCount: cfg.Cache.DeleteScanCount,
|
||||||
})
|
})
|
||||||
brandService := tenantapp.NewBrandService(pool, monitoringPool, auditLogs, cfg.BrandLibrary).WithCache(appCache)
|
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)
|
monitoringService := tenantapp.NewMonitoringService(pool, monitoringPool, mqClient, cfg.MonitoringDispatch, cfg.BrandLibrary, logger).WithRedis(rdb)
|
||||||
kolProfiles := repository.NewKolProfileRepository(pool)
|
kolProfiles := repository.NewKolProfileRepository(pool)
|
||||||
kolPackages := repository.NewKolPackageRepository(pool)
|
kolPackages := repository.NewKolPackageRepository(pool)
|
||||||
@@ -214,6 +216,7 @@ func New(configPath string) (*App, error) {
|
|||||||
DesktopDispatch: desktopDispatch,
|
DesktopDispatch: desktopDispatch,
|
||||||
Cache: appCache,
|
Cache: appCache,
|
||||||
BrandService: brandService,
|
BrandService: brandService,
|
||||||
|
QuestionExpansion: questionExpansion,
|
||||||
MonitoringService: monitoringService,
|
MonitoringService: monitoringService,
|
||||||
KolProfiles: kolProfiles,
|
KolProfiles: kolProfiles,
|
||||||
KolPackages: kolPackages,
|
KolPackages: kolPackages,
|
||||||
|
|||||||
@@ -237,10 +237,11 @@ func (p MembershipPlanConfig) QuotaPolicyJSON() ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type BrandLibraryConfig struct {
|
type BrandLibraryConfig struct {
|
||||||
FreeBrandLimit int `mapstructure:"free_brand_limit"`
|
FreeBrandLimit int `mapstructure:"free_brand_limit"`
|
||||||
PaidBrandLimit int `mapstructure:"paid_brand_limit"`
|
PaidBrandLimit int `mapstructure:"paid_brand_limit"`
|
||||||
MaxKeywords int `mapstructure:"max_keywords"`
|
MaxKeywords int `mapstructure:"max_keywords"`
|
||||||
MaxQuestionsPerKeyword int `mapstructure:"max_questions_per_keyword"`
|
MaxQuestionsPerKeyword int `mapstructure:"max_questions_per_keyword"`
|
||||||
|
QuestionLimitsByPlan map[string]int `mapstructure:"question_limits_by_plan"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c BrandLibraryConfig) BrandLimitForPlan(planCode string) int {
|
func (c BrandLibraryConfig) BrandLimitForPlan(planCode string) int {
|
||||||
@@ -250,6 +251,20 @@ func (c BrandLibraryConfig) BrandLimitForPlan(planCode string) int {
|
|||||||
return c.PaidBrandLimit
|
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 {
|
type QdrantConfig struct {
|
||||||
URL string `mapstructure:"url"`
|
URL string `mapstructure:"url"`
|
||||||
APIKey string `mapstructure:"api_key"`
|
APIKey string `mapstructure:"api_key"`
|
||||||
@@ -1173,6 +1188,29 @@ func normalizeBrandLibraryConfig(cfg *BrandLibraryConfig) {
|
|||||||
if cfg.MaxQuestionsPerKeyword <= 0 {
|
if cfg.MaxQuestionsPerKeyword <= 0 {
|
||||||
cfg.MaxQuestionsPerKeyword = 5
|
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) {
|
func normalizeMembershipConfig(cfg *MembershipConfig) {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ func Diff(previous, current *Config) []FieldChange {
|
|||||||
if !reflect.DeepEqual(previous.Membership, current.Membership) {
|
if !reflect.DeepEqual(previous.Membership, current.Membership) {
|
||||||
addChange("membership", true)
|
addChange("membership", true)
|
||||||
}
|
}
|
||||||
if previous.BrandLibrary != current.BrandLibrary {
|
if !reflect.DeepEqual(previous.BrandLibrary, current.BrandLibrary) {
|
||||||
addChange("brand_library", true)
|
addChange("brand_library", true)
|
||||||
}
|
}
|
||||||
if previous.Qdrant != current.Qdrant {
|
if previous.Qdrant != current.Qdrant {
|
||||||
|
|||||||
@@ -175,10 +175,14 @@ var routeDocs = map[string]routeDoc{
|
|||||||
"PUT /api/tenant/brands/:id/keywords/:kid": {"更新关键词", "修改关键词文本/分组。"},
|
"PUT /api/tenant/brands/:id/keywords/:kid": {"更新关键词", "修改关键词文本/分组。"},
|
||||||
"DELETE /api/tenant/brands/:id/keywords/:kid": {"删除关键词", "删除某条关键词。"},
|
"DELETE /api/tenant/brands/:id/keywords/:kid": {"删除关键词", "删除某条关键词。"},
|
||||||
|
|
||||||
"GET /api/tenant/brands/:id/questions": {"品牌问题列表", "返回品牌下的监控问题,可按 keyword_id 过滤。"},
|
"GET /api/tenant/brands/:id/questions": {"品牌问题列表", "返回品牌下的监控问题,可按 keyword_id 过滤。"},
|
||||||
"POST /api/tenant/brands/:id/questions": {"新增监控问题", "为品牌添加一条 GEO 监控问题。"},
|
"POST /api/tenant/brands/:id/questions": {"新增监控问题", "为品牌添加一条 GEO 监控问题。"},
|
||||||
"PUT /api/tenant/brands/:id/questions/:qid": {"更新监控问题", "修改问题文本或所属关键词。"},
|
"POST /api/tenant/brands/:id/questions/combination-preview": {"拓词工具预览", "按地域词、前缀词、核心词、行业词、后缀词组合生成问题候选,仅预览不入库。"},
|
||||||
"DELETE /api/tenant/brands/:id/questions/:qid": {"删除监控问题", "删除某条监控问题。"},
|
"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": {"删除监控问题", "删除某条监控问题。"},
|
||||||
|
|
||||||
"GET /api/tenant/brands/:id/competitors": {"竞品列表", "返回品牌下登记的竞品。"},
|
"GET /api/tenant/brands/:id/competitors": {"竞品列表", "返回品牌下登记的竞品。"},
|
||||||
"POST /api/tenant/brands/:id/competitors": {"新增竞品", "为品牌添加竞品记录。"},
|
"POST /api/tenant/brands/:id/competitors": {"新增竞品", "为品牌添加竞品记录。"},
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const (
|
|||||||
AIUsageTypeTemplateOutlineGenerate = "template_outline_generate"
|
AIUsageTypeTemplateOutlineGenerate = "template_outline_generate"
|
||||||
AIUsageTypeKolPromptGenerate = "kol_prompt_generate"
|
AIUsageTypeKolPromptGenerate = "kol_prompt_generate"
|
||||||
AIUsageTypeKolPromptOptimize = "kol_prompt_optimize"
|
AIUsageTypeKolPromptOptimize = "kol_prompt_optimize"
|
||||||
|
AIUsageTypeQuestionDistill = "question_distill"
|
||||||
|
|
||||||
aiPointsQuotaType = "ai_points"
|
aiPointsQuotaType = "ai_points"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -68,15 +68,16 @@ type BrandRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type BrandResponse struct {
|
type BrandResponse struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Website *string `json:"website"`
|
Website *string `json:"website"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
KeywordCount int `json:"keyword_count"`
|
KeywordCount int `json:"keyword_count"`
|
||||||
QuestionCount int `json:"question_count"`
|
QuestionCount int `json:"question_count"`
|
||||||
CreatedAt string `json:"created_at"`
|
CompetitorCount int `json:"competitor_count"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BrandLibrarySummaryResponse struct {
|
type BrandLibrarySummaryResponse struct {
|
||||||
@@ -88,7 +89,11 @@ type BrandLibrarySummaryResponse struct {
|
|||||||
MaxKeywords int `json:"max_keywords"`
|
MaxKeywords int `json:"max_keywords"`
|
||||||
UsedKeywords int `json:"used_keywords"`
|
UsedKeywords int `json:"used_keywords"`
|
||||||
RemainingKeywords int `json:"remaining_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"`
|
MaxQuestionsPerKeyword int `json:"max_questions_per_keyword"`
|
||||||
|
MaxQuestionsPerBrand int `json:"max_questions_per_brand"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *BrandService) List(ctx context.Context) ([]BrandResponse, error) {
|
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
|
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 id int64
|
||||||
var ca interface{}
|
var ca interface{}
|
||||||
err = s.pool.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
WITH usage AS (
|
WITH usage AS (
|
||||||
SELECT COUNT(*)::INT AS used_brands
|
SELECT COUNT(*)::INT AS used_brands
|
||||||
FROM 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")
|
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})
|
afterJSON, _ := json.Marshal(map[string]interface{}{"id": id, "name": req.Name})
|
||||||
result := "success"
|
result := "success"
|
||||||
resourceType := "brand"
|
resourceType := "brand"
|
||||||
@@ -159,14 +179,15 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp
|
|||||||
|
|
||||||
invalidateBrandCaches(ctx, s.cache, actor.TenantID, id)
|
invalidateBrandCaches(ctx, s.cache, actor.TenantID, id)
|
||||||
return &BrandResponse{
|
return &BrandResponse{
|
||||||
ID: id,
|
ID: id,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Website: req.Website,
|
Website: req.Website,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
Status: "active",
|
Status: "active",
|
||||||
KeywordCount: 0,
|
KeywordCount: 0,
|
||||||
QuestionCount: 0,
|
QuestionCount: 0,
|
||||||
CreatedAt: fmt.Sprintf("%v", ca),
|
CompetitorCount: 0,
|
||||||
|
CreatedAt: fmt.Sprintf("%v", ca),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,29 +491,47 @@ func (s *BrandService) CreateQuestion(ctx context.Context, brandID int64, req Qu
|
|||||||
return nil, err
|
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 questionID int64
|
||||||
var ca interface{}
|
var ca interface{}
|
||||||
err = s.pool.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
WITH usage AS (
|
WITH usage AS (
|
||||||
SELECT COUNT(*)::INT AS used_questions
|
SELECT COUNT(*)::INT AS used_questions
|
||||||
FROM brand_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)
|
INSERT INTO brand_questions (tenant_id, brand_id, keyword_id, question_text, status)
|
||||||
SELECT $1, $2, $3, $4, 'active'
|
SELECT $1, $2, $3, $4, 'active'
|
||||||
FROM usage
|
FROM usage
|
||||||
WHERE usage.used_questions < $5
|
WHERE usage.used_questions < $5
|
||||||
RETURNING id, created_at
|
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 err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
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) {
|
if isForeignKeyConstraintError(err) {
|
||||||
return nil, response.ErrNotFound(40421, "keyword_not_found", "keyword not found")
|
return nil, response.ErrNotFound(40421, "keyword_not_found", "keyword not found")
|
||||||
}
|
}
|
||||||
return nil, response.ErrInternal(50010, "create_failed", "failed to create question")
|
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)
|
invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID)
|
||||||
return &QuestionResponse{
|
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")
|
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, `
|
tag, err := s.pool.Exec(ctx, `
|
||||||
UPDATE brand_questions SET question_text = $1, updated_at = NOW()
|
UPDATE brand_questions
|
||||||
WHERE id = $2 AND brand_id = $3 AND tenant_id = $4 AND deleted_at IS NULL
|
SET question_text = $1,
|
||||||
`, req.QuestionText, questionID, brandID, actor.TenantID)
|
layer = $2,
|
||||||
if err != nil || tag.RowsAffected() == 0 {
|
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")
|
return response.ErrNotFound(40422, "question_not_found", "question not found")
|
||||||
}
|
}
|
||||||
invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID)
|
invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID)
|
||||||
@@ -641,24 +695,35 @@ func (s *BrandService) loadBrands(ctx context.Context, tenantID int64) ([]BrandR
|
|||||||
b.website,
|
b.website,
|
||||||
b.description,
|
b.description,
|
||||||
b.status,
|
b.status,
|
||||||
COALESCE(stats.keyword_count, 0) AS keyword_count,
|
COALESCE(keyword_stats.keyword_count, 0) AS keyword_count,
|
||||||
COALESCE(stats.question_count, 0) AS question_count,
|
COALESCE(question_stats.question_count, 0) AS question_count,
|
||||||
|
COALESCE(competitor_stats.competitor_count, 0) AS competitor_count,
|
||||||
b.created_at,
|
b.created_at,
|
||||||
b.updated_at
|
b.updated_at
|
||||||
FROM brands b
|
FROM brands b
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT k.id)::INT AS keyword_count,
|
COUNT(DISTINCT k.id)::INT AS keyword_count
|
||||||
COUNT(q.id)::INT AS question_count
|
|
||||||
FROM brand_keywords k
|
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
|
WHERE k.brand_id = b.id
|
||||||
AND k.tenant_id = b.tenant_id
|
AND k.tenant_id = b.tenant_id
|
||||||
|
AND COALESCE(k.source, 'manual') <> 'auto'
|
||||||
AND k.deleted_at IS NULL
|
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
|
WHERE b.tenant_id = $1 AND b.deleted_at IS NULL
|
||||||
ORDER BY b.created_at DESC
|
ORDER BY b.created_at DESC
|
||||||
`, tenantID)
|
`, tenantID)
|
||||||
@@ -672,7 +737,7 @@ func (s *BrandService) loadBrands(ctx context.Context, tenantID int64) ([]BrandR
|
|||||||
var item BrandResponse
|
var item BrandResponse
|
||||||
var createdAt interface{}
|
var createdAt interface{}
|
||||||
var updatedAt 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())
|
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
|
||||||
}
|
}
|
||||||
item.CreatedAt = fmt.Sprintf("%v", createdAt)
|
item.CreatedAt = fmt.Sprintf("%v", createdAt)
|
||||||
@@ -693,26 +758,37 @@ func (s *BrandService) loadBrandDetail(ctx context.Context, tenantID, brandID in
|
|||||||
b.website,
|
b.website,
|
||||||
b.description,
|
b.description,
|
||||||
b.status,
|
b.status,
|
||||||
COALESCE(stats.keyword_count, 0) AS keyword_count,
|
COALESCE(keyword_stats.keyword_count, 0) AS keyword_count,
|
||||||
COALESCE(stats.question_count, 0) AS question_count,
|
COALESCE(question_stats.question_count, 0) AS question_count,
|
||||||
|
COALESCE(competitor_stats.competitor_count, 0) AS competitor_count,
|
||||||
b.created_at,
|
b.created_at,
|
||||||
b.updated_at
|
b.updated_at
|
||||||
FROM brands b
|
FROM brands b
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT k.id)::INT AS keyword_count,
|
COUNT(DISTINCT k.id)::INT AS keyword_count
|
||||||
COUNT(q.id)::INT AS question_count
|
|
||||||
FROM brand_keywords k
|
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
|
WHERE k.brand_id = b.id
|
||||||
AND k.tenant_id = b.tenant_id
|
AND k.tenant_id = b.tenant_id
|
||||||
|
AND COALESCE(k.source, 'manual') <> 'auto'
|
||||||
AND k.deleted_at IS NULL
|
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
|
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 err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
@@ -726,8 +802,12 @@ func (s *BrandService) loadBrandDetail(ctx context.Context, tenantID, brandID in
|
|||||||
|
|
||||||
func (s *BrandService) loadBrandKeywords(ctx context.Context, tenantID, brandID int64) ([]KeywordResponse, error) {
|
func (s *BrandService) loadBrandKeywords(ctx context.Context, tenantID, brandID int64) ([]KeywordResponse, error) {
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT id, brand_id, name, status, created_at FROM brand_keywords
|
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)
|
`, brandID, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(50010, "query_failed", "failed to list keywords")
|
return nil, response.ErrInternal(50010, "query_failed", "failed to list keywords")
|
||||||
@@ -808,14 +888,14 @@ func (s *BrandService) loadBrandCompetitors(ctx context.Context, tenantID, brand
|
|||||||
}
|
}
|
||||||
|
|
||||||
type brandLibraryPlan struct {
|
type brandLibraryPlan struct {
|
||||||
PlanCode string
|
PlanCode string
|
||||||
PlanName string
|
PlanName string
|
||||||
MaxBrands int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type brandLibraryUsage struct {
|
type brandLibraryUsage struct {
|
||||||
BrandCount int
|
BrandCount int
|
||||||
KeywordCount int
|
KeywordCount int
|
||||||
|
QuestionCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *BrandService) loadBrandLibrarySummary(ctx context.Context, tenantID int64) (*BrandLibrarySummaryResponse, error) {
|
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()
|
limits := s.currentLimits()
|
||||||
maxBrands := limits.BrandLimitForPlan(plan.PlanCode)
|
maxBrands := limits.BrandLimitForPlan(plan.PlanCode)
|
||||||
if plan.MaxBrands > 0 {
|
|
||||||
maxBrands = plan.MaxBrands
|
|
||||||
}
|
|
||||||
maxKeywords := limits.MaxKeywords
|
maxKeywords := limits.MaxKeywords
|
||||||
|
maxQuestions := limits.QuestionLimitForPlan(plan.PlanCode)
|
||||||
|
|
||||||
return &BrandLibrarySummaryResponse{
|
return &BrandLibrarySummaryResponse{
|
||||||
PlanCode: plan.PlanCode,
|
PlanCode: plan.PlanCode,
|
||||||
@@ -845,21 +923,22 @@ func (s *BrandService) loadBrandLibrarySummary(ctx context.Context, tenantID int
|
|||||||
MaxKeywords: maxKeywords,
|
MaxKeywords: maxKeywords,
|
||||||
UsedKeywords: usage.KeywordCount,
|
UsedKeywords: usage.KeywordCount,
|
||||||
RemainingKeywords: maxInt(maxKeywords-usage.KeywordCount, 0),
|
RemainingKeywords: maxInt(maxKeywords-usage.KeywordCount, 0),
|
||||||
|
MaxQuestions: maxQuestions,
|
||||||
|
UsedQuestions: usage.QuestionCount,
|
||||||
|
RemainingQuestions: maxInt(maxQuestions-usage.QuestionCount, 0),
|
||||||
MaxQuestionsPerKeyword: limits.MaxQuestionsPerKeyword,
|
MaxQuestionsPerKeyword: limits.MaxQuestionsPerKeyword,
|
||||||
|
MaxQuestionsPerBrand: maxQuestions,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *BrandService) loadBrandLibraryPlan(ctx context.Context, tenantID int64) (*brandLibraryPlan, error) {
|
func (s *BrandService) loadBrandLibraryPlan(ctx context.Context, tenantID int64) (*brandLibraryPlan, error) {
|
||||||
limits := s.currentLimits()
|
|
||||||
plan := &brandLibraryPlan{
|
plan := &brandLibraryPlan{
|
||||||
PlanCode: "free",
|
PlanCode: "free",
|
||||||
PlanName: "",
|
PlanName: "",
|
||||||
MaxBrands: limits.BrandLimitForPlan("free"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var quotaPolicyJSON []byte
|
|
||||||
err := s.pool.QueryRow(ctx, `
|
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
|
FROM tenant_plan_subscriptions s
|
||||||
JOIN plans p ON p.id = s.plan_id
|
JOIN plans p ON p.id = s.plan_id
|
||||||
WHERE s.tenant_id = $1
|
WHERE s.tenant_id = $1
|
||||||
@@ -869,22 +948,13 @@ func (s *BrandService) loadBrandLibraryPlan(ctx context.Context, tenantID int64)
|
|||||||
AND s.end_at > $2
|
AND s.end_at > $2
|
||||||
ORDER BY s.start_at DESC
|
ORDER BY s.start_at DESC
|
||||||
LIMIT 1
|
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 err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return plan, nil
|
return plan, nil
|
||||||
}
|
}
|
||||||
return nil, response.ErrInternal(50010, "query_failed", "failed to load active plan")
|
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
|
return plan, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -893,8 +963,9 @@ func (s *BrandService) loadBrandLibraryUsage(ctx context.Context, tenantID int64
|
|||||||
err := s.pool.QueryRow(ctx, `
|
err := s.pool.QueryRow(ctx, `
|
||||||
SELECT
|
SELECT
|
||||||
(SELECT COUNT(*)::INT FROM brands WHERE tenant_id = $1 AND deleted_at IS NULL) AS brand_count,
|
(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
|
(SELECT COUNT(*)::INT FROM brand_keywords WHERE tenant_id = $1 AND deleted_at IS NULL AND COALESCE(source, 'manual') <> 'auto') AS keyword_count,
|
||||||
`, tenantID).Scan(&usage.BrandCount, &usage.KeywordCount)
|
(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 {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(50010, "query_failed", "failed to load brand library usage")
|
return nil, response.ErrInternal(50010, "query_failed", "failed to load brand library usage")
|
||||||
}
|
}
|
||||||
@@ -919,7 +990,7 @@ func (s *BrandService) brandExists(ctx context.Context, tenantID, brandID int64)
|
|||||||
func (s *BrandService) keywordExistsForBrand(ctx context.Context, tenantID, brandID, keywordID int64) (bool, error) {
|
func (s *BrandService) keywordExistsForBrand(ctx context.Context, tenantID, brandID, keywordID int64) (bool, error) {
|
||||||
var exists bool
|
var exists bool
|
||||||
err := s.pool.QueryRow(ctx, `
|
err := s.pool.QueryRow(ctx, `
|
||||||
SELECT EXISTS (
|
SELECT EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM brand_keywords
|
FROM brand_keywords
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
@@ -934,6 +1005,82 @@ func (s *BrandService) keywordExistsForBrand(ctx context.Context, tenantID, bran
|
|||||||
return exists, nil
|
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 {
|
func isUniqueConstraintError(err error, constraint string) bool {
|
||||||
var pgErr *pgconn.PgError
|
var pgErr *pgconn.PgError
|
||||||
return errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == constraint
|
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)
|
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 {
|
func brandDetailCacheKey(tenantID, brandID int64) string {
|
||||||
return fmt.Sprintf("brand:detail:%d:%d", tenantID, brandID)
|
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")
|
brands := tenantProtected.Group("/brands")
|
||||||
brandHandler := NewBrandHandler(app)
|
brandHandler := NewBrandHandler(app)
|
||||||
|
questionExpansionHandler := NewQuestionExpansionHandler(app)
|
||||||
brands.GET("", brandHandler.List)
|
brands.GET("", brandHandler.List)
|
||||||
brands.GET("/library-summary", brandHandler.Summary)
|
brands.GET("/library-summary", brandHandler.Summary)
|
||||||
brands.POST("", brandHandler.Create)
|
brands.POST("", brandHandler.Create)
|
||||||
@@ -188,6 +189,10 @@ func RegisterRoutes(app *bootstrap.App) {
|
|||||||
brands.DELETE("/:id/keywords/:kid", brandHandler.DeleteKeyword)
|
brands.DELETE("/:id/keywords/:kid", brandHandler.DeleteKeyword)
|
||||||
brands.GET("/:id/questions", brandHandler.ListQuestions)
|
brands.GET("/:id/questions", brandHandler.ListQuestions)
|
||||||
brands.POST("/:id/questions", brandHandler.CreateQuestion)
|
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.PUT("/:id/questions/:qid", brandHandler.UpdateQuestion)
|
||||||
brands.DELETE("/:id/questions/:qid", brandHandler.DeleteQuestion)
|
brands.DELETE("/:id/questions/:qid", brandHandler.DeleteQuestion)
|
||||||
brands.GET("/:id/competitors", brandHandler.ListCompetitors)
|
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