@@ -2789,22 +2953,12 @@ function onStructureDragEnd(): void {
-
-
-
-
{{ assistHeading }}
-
-
{{ assistStages[assistStageIndex] }}
-
-
+ :title="assistHeading"
+ :progress="assistProgress"
+ :stage="assistStages[assistStageIndex]"
+ />
diff --git a/apps/admin-web/src/views/TrackingView.vue b/apps/admin-web/src/views/TrackingView.vue
index a089172..781fa61 100644
--- a/apps/admin-web/src/views/TrackingView.vue
+++ b/apps/admin-web/src/views/TrackingView.vue
@@ -96,9 +96,10 @@ const trackingPlatformOptions = [
value: platform.id,
})),
] as const
+const allQuestionOptionValue = 'all'
const selectedBrandId = useStorage('tracking_selected_brand', null)
-const selectedKeywordId = useStorage('tracking_selected_keyword', null)
+const selectedQuestionId = useStorage('tracking_selected_question', null)
const selectedPlatformId = useStorage('tracking_selected_ai_platform_id', 'all')
const selectedBusinessDate = useStorage('tracking_selected_business_date', trackingToday)
const selectedCitationWindowDays = useStorage('tracking_selected_citation_window_days', 7)
@@ -108,6 +109,14 @@ selectedBusinessDate.value =
normalizeTrackingBusinessDate(selectedBusinessDate.value) ?? trackingToday
selectedCitationWindowDays.value = normalizeCitationWindowDays(selectedCitationWindowDays.value)
+const selectedQuestionSetOptionValue = computed({
+ get: () => selectedQuestionId.value ?? allQuestionOptionValue,
+ set: (value) => {
+ selectedQuestionId.value =
+ typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
+ },
+})
+
const brandsQuery = useQuery({
queryKey: ['tracking', 'brands'],
queryFn: () => brandsApi.list(),
@@ -135,37 +144,48 @@ watch(
{ immediate: true },
)
-const keywordsQuery = useQuery({
- queryKey: computed(() => ['tracking', 'keywords', selectedBrandId.value]),
+const questionsQuery = useQuery({
+ queryKey: computed(() => ['tracking', 'questions', selectedBrandId.value]),
enabled: computed(() => Boolean(selectedBrandId.value)),
- queryFn: () => brandsApi.listKeywords(selectedBrandId.value as number),
+ queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
})
+const questionSetOptions = computed(() => [
+ { label: t('tracking.allQuestionSets'), value: allQuestionOptionValue },
+ ...(questionsQuery.data.value ?? []).map((item) => ({
+ label: item.question_text,
+ value: item.id,
+ })),
+])
+
watch(
- () => keywordsQuery.data.value,
- (keywords) => {
- if (!keywords?.length) {
+ () => questionsQuery.data.value,
+ (questions) => {
+ const requestedQuestionId = parseNumericQuery(route.query.question_id)
+ if (!questions?.length) {
+ if (!requestedQuestionId) {
+ selectedQuestionId.value = null
+ }
return
}
- const requestedKeywordId = parseNumericQuery(route.query.keyword_id)
- if (requestedKeywordId && keywords.some((item) => item.id === requestedKeywordId)) {
- selectedKeywordId.value = requestedKeywordId
+ if (requestedQuestionId && questions.some((item) => item.id === requestedQuestionId)) {
+ selectedQuestionId.value = requestedQuestionId
return
}
- if (selectedKeywordId.value && keywords.some((item) => item.id === selectedKeywordId.value)) {
+ if (selectedQuestionId.value && questions.some((item) => item.id === selectedQuestionId.value)) {
return
}
- selectedKeywordId.value = keywords[0].id
+ selectedQuestionId.value = null
},
{ immediate: true },
)
watch(selectedBrandId, (newId, oldId) => {
if (oldId && newId !== oldId) {
- selectedKeywordId.value = null
+ selectedQuestionId.value = null
}
})
@@ -189,14 +209,25 @@ watch(
)
watch(
- [selectedBrandId, selectedKeywordId, selectedPlatformId, selectedBusinessDate],
- ([brandId, keywordId, platformId, businessDate]) => {
+ [
+ selectedBrandId,
+ selectedQuestionId,
+ selectedPlatformId,
+ selectedBusinessDate,
+ () => questionsQuery.data.value,
+ ],
+ ([brandId, questionId, platformId, businessDate, questions]) => {
+ const requestedQuestionId = parseNumericQuery(route.query.question_id)
+ if (requestedQuestionId && !questionId && !Array.isArray(questions)) {
+ return
+ }
+
const nextBrandId = brandId ? String(brandId) : undefined
- const nextKeywordId = keywordId ? String(keywordId) : undefined
+ const nextQuestionId = questionId ? String(questionId) : undefined
const nextPlatformId = platformId !== 'all' ? platformId : undefined
const nextBusinessDate = normalizeTrackingBusinessDate(businessDate) ?? trackingToday
const currentBrandId = normalizeQueryValue(route.query.brand_id) || undefined
- const currentKeywordId = normalizeQueryValue(route.query.keyword_id) || undefined
+ const currentQuestionId = normalizeQueryValue(route.query.question_id) || undefined
const currentPlatformId = normalizeMonitoringPlatformFilter(route.query.ai_platform_id)
const currentBusinessDate =
normalizeTrackingBusinessDate(route.query.business_date) ?? trackingToday
@@ -204,7 +235,7 @@ watch(
if (
currentBrandId === nextBrandId &&
- currentKeywordId === nextKeywordId &&
+ currentQuestionId === nextQuestionId &&
currentPlatformId === (nextPlatformId ?? 'all') &&
currentBusinessDate === nextBusinessDate &&
!hasLegacyCitationDaysQuery
@@ -216,7 +247,7 @@ watch(
name: 'tracking',
query: {
...(nextBrandId ? { brand_id: nextBrandId } : {}),
- ...(nextKeywordId ? { keyword_id: nextKeywordId } : {}),
+ ...(nextQuestionId ? { question_id: nextQuestionId } : {}),
...(nextPlatformId ? { ai_platform_id: nextPlatformId } : {}),
business_date: nextBusinessDate,
},
@@ -229,7 +260,7 @@ const dashboardQuery = useQuery({
'tracking',
'dashboard',
selectedBrandId.value,
- selectedKeywordId.value,
+ selectedQuestionId.value,
selectedPlatformId.value,
selectedBusinessDate.value,
]),
@@ -237,7 +268,7 @@ const dashboardQuery = useQuery({
queryFn: () =>
monitoringApi.dashboardComposite({
brand_id: selectedBrandId.value ?? undefined,
- keyword_id: selectedKeywordId.value,
+ question_id: selectedQuestionId.value,
days: trackingMaxHistoryDays,
ai_platform_id: selectedPlatformId.value !== 'all' ? selectedPlatformId.value : undefined,
business_date: selectedBusinessDate.value,
@@ -249,7 +280,7 @@ const citationSummaryQuery = useQuery({
'tracking',
'citation-summary',
selectedBrandId.value,
- selectedKeywordId.value,
+ selectedQuestionId.value,
selectedPlatformId.value,
selectedBusinessDate.value,
selectedCitationWindowDays.value,
@@ -258,7 +289,7 @@ const citationSummaryQuery = useQuery({
queryFn: () =>
monitoringApi.citationSummary({
brand_id: selectedBrandId.value ?? undefined,
- keyword_id: selectedKeywordId.value,
+ question_id: selectedQuestionId.value,
business_date: selectedBusinessDate.value,
ai_platform_id: selectedPlatformId.value !== 'all' ? selectedPlatformId.value : undefined,
days: selectedCitationWindowDays.value,
@@ -275,12 +306,9 @@ const collectNowMutation = useMutation({
if (!selectedBrandId.value) {
throw new Error('tracking_brand_required')
}
- if (!selectedKeywordId.value) {
- throw new Error('tracking_keyword_required')
- }
return monitoringApi.collectNow(selectedBrandId.value, {
- keyword_id: selectedKeywordId.value,
+ question_id: selectedQuestionId.value ?? undefined,
platform_ids: selectedPlatformId.value !== 'all' ? [selectedPlatformId.value] : undefined,
})
},
@@ -293,10 +321,6 @@ const collectNowMutation = useMutation({
message.warning(t('tracking.collectBrandRequired'))
return
}
- if (error instanceof Error && error.message === 'tracking_keyword_required') {
- message.warning(t('tracking.collectKeywordRequired'))
- return
- }
message.error(formatError(error))
},
})
@@ -343,9 +367,6 @@ const collectNowDisabledReason = computed(() => {
if (!selectedBrandId.value) {
return t('tracking.collectBrandRequired')
}
- if (!selectedKeywordId.value) {
- return t('tracking.collectKeywordRequired')
- }
if (selectedBusinessDate.value !== trackingToday) {
return t('tracking.collectTodayOnly')
}
@@ -649,11 +670,20 @@ function openQuestion(question: MonitoringHotQuestion): void {
date_from: selectedBusinessDate.value,
date_to: selectedBusinessDate.value,
...(selectedPlatformId.value !== 'all' ? { ai_platform_id: selectedPlatformId.value } : {}),
- ...(selectedKeywordId.value ? { keyword_id: String(selectedKeywordId.value) } : {}),
},
})
}
+function filterQuestionOption(input: string, option?: { label?: string }): boolean {
+ const keyword = input.trim().toLowerCase()
+ if (!keyword) {
+ return true
+ }
+ return String(option?.label ?? '')
+ .toLowerCase()
+ .includes(keyword)
+}
+
function formatPercent(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '--'
@@ -832,18 +862,16 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
/>
diff --git a/packages/http-client/src/index.ts b/packages/http-client/src/index.ts
index e799b78..f28ae5f 100644
--- a/packages/http-client/src/index.ts
+++ b/packages/http-client/src/index.ts
@@ -61,8 +61,11 @@ function normalizeError(error: unknown): ApiClientError {
if (axios.isAxiosError(error)) {
const payload = error.response?.data as Partial
| undefined
+ const isTimeout =
+ !error.response && (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT')
+ const fallbackMessage = isTimeout ? 'request_timeout' : (error.message ?? 'network_error')
return new ApiClientError({
- message: payload?.message ?? error.message ?? 'network_error',
+ message: payload?.message ?? fallbackMessage,
code: payload?.code ?? 50000,
status: error.response?.status,
detail: payload?.detail,
diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts
index cfd4347..901aee0 100644
--- a/packages/shared-types/src/index.ts
+++ b/packages/shared-types/src/index.ts
@@ -472,6 +472,8 @@ export interface TemplateAnalyzeTaskRequest {
brand_id?: number | null
brand_name?: string
website?: string | null
+ brand_question?: string
+ supplemental_questions?: string[]
input_params?: Record
existing_keywords?: string[]
existing_competitors?: TemplateAssistCompetitor[]
@@ -489,6 +491,8 @@ export interface TemplateTitleTaskRequest {
brand_name?: string
website?: string | null
brand_summary?: string | null
+ brand_question?: string
+ supplemental_questions?: string[]
input_params?: Record
keywords?: string[]
competitors?: TemplateAssistCompetitor[]
@@ -506,6 +510,8 @@ export interface TemplateOutlineTaskRequest {
website?: string | null
brand_summary?: string | null
title: string
+ brand_question?: string
+ supplemental_questions?: string[]
input_params?: Record
keywords?: string[]
competitors?: TemplateAssistCompetitor[]
@@ -1278,6 +1284,24 @@ export interface QuestionCombinationRequest {
max_items?: number
}
+export interface QuestionCombinationFillRequest {
+ region?: string[]
+ prefix?: string[]
+ core?: string[]
+ industry?: string[]
+ suffix?: string[]
+}
+
+export interface QuestionCombinationFillResult {
+ region: string[]
+ prefix: string[]
+ core: string[]
+ industry: string[]
+ suffix: string[]
+ ai_points_charged?: number
+ cache_hit?: boolean
+}
+
export interface QuestionDistillRequest {
seed_topic: string
}
diff --git a/server/cmd/ops-api/main.go b/server/cmd/ops-api/main.go
index f7699d8..9578bde 100644
--- a/server/cmd/ops-api/main.go
+++ b/server/cmd/ops-api/main.go
@@ -19,6 +19,7 @@ import (
sharedauth "github.com/geo-platform/tenant-api/internal/shared/auth"
sharedbootstrap "github.com/geo-platform/tenant-api/internal/shared/bootstrap"
"github.com/geo-platform/tenant-api/internal/shared/cache"
+ "github.com/geo-platform/tenant-api/internal/shared/ipregion"
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
"github.com/geo-platform/tenant-api/internal/shared/observability"
"github.com/geo-platform/tenant-api/internal/shared/repository/postgres"
@@ -83,11 +84,13 @@ func main() {
auditsRepo := repository.NewAuditRepository(pool)
siteDomainMappingsRepo := repository.NewSiteDomainMappingRepository(monitoringPool)
- ipRegionResolver, err := app.NewIPRegionResolver(cfg.IPRegion.V4XDBPath, cfg.IPRegion.V6XDBPath, logger)
+ ipRegionResolver, err := ipregion.NewResolver(cfg.IPRegion.V4XDBPath, cfg.IPRegion.V6XDBPath, logger)
if err != nil {
logger.Sugar().Warnf("ops-api ip2region disabled: %v", err)
}
- defer ipRegionResolver.Close()
+ if ipRegionResolver != nil {
+ defer ipRegionResolver.Close()
+ }
auditSvc := app.NewAuditService(auditsRepo, logger).WithIPRegionResolver(ipRegionResolver)
issuer := app.NewTokenIssuer(cfg.JWT.Secret, cfg.JWT.AccessTTL)
diff --git a/server/configs/prompts.yml b/server/configs/prompts.yml
index cf780dc..5fb0f22 100644
--- a/server/configs/prompts.yml
+++ b/server/configs/prompts.yml
@@ -6,6 +6,8 @@ runtime:
- 仅返回文章 Markdown 正文,不要附带额外说明、提示语或代码块。
- 输出语言与 locale 一致:zh-CN 使用简体中文,en-US 使用自然、专业的英语。
- 如提供了 title,使用该标题作为文章主标题,并围绕它展开,不要另起一个无关标题。
+ - 如提供了 supplemental_questions,它们只用于文章主体覆盖,不得改写文章主标题,不得作为 H1,也不要喧宾夺主替代 brand_question。
+ - 结尾需用 1 句加粗点题自然收束 supplemental_questions 覆盖过的问题;如果没有 supplemental_questions,则按常规结尾。
- 如提供了 article_outline,一级节点是正文的最终小节标题,必须按顺序展开;二级及更深节点仅作为该小节的行文思路、论证顺序或信息要点,不要机械写成额外标题。
- 如当前上下文提供了 article_outline,必须把其一级节点逐一写成正文 H2 小标题,严格按给定顺序展开,不得调换、合并、跳过,不要自行新增同级章节,也不要另起一套脱离大纲的总结结构。
- 除主标题和一级小节标题外,默认不要把大纲子节点直接写成 Markdown 标题;子节点内容应自然融入段落、列表或过渡句中。
@@ -46,9 +48,12 @@ runtime:
brand: "品牌"
official_website: "官网"
website: "官网"
- primary_keyword: "核心关键词"
- keywords: "关键词"
- existing_keywords: "关键词"
+ brand_question: "品牌主问题"
+ primary_question: "品牌主问题"
+ supplemental_questions: "补充覆盖问题"
+ primary_keyword: "品牌主问题"
+ keywords: "品牌问题"
+ existing_keywords: "搜索问题"
competitors: "竞品"
existing_competitors: "竞品"
competitor_names: "竞品名称"
@@ -57,7 +62,7 @@ runtime:
category: "品类"
count: "数量"
top_count: "推荐数量"
- keyword_count: "关键词数量"
+ keyword_count: "品牌问题数量"
depth: "深度"
article_outline: "文章大纲"
outline_sections: "已选段落"
@@ -100,20 +105,20 @@ runtime:
任务:
1. 分析品牌/主题上下文。
- 2. 推荐最相关的 GEO 搜索查询词,优先输出真实用户会直接搜索的问题型长尾词。
+ 2. 推荐最相关的 GEO 搜索问题,优先输出真实用户会直接搜索的问题型长尾词。
3. 推荐可信的竞品网站或竞争品牌,用于对比或引用。
规则:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
- - keywords 不是泛泛的标签词,而是搜索框里的完整查询;优先覆盖“哪家好 / 有哪些 / 推荐 / 怎么选 / 价格 / 环保标准 / 对比 / 排名 / 注意事项 / 避坑”等决策意图。
+ - keywords 是后端兼容字段,语义是搜索问题候选,不是泛泛的标签词;优先覆盖“哪家好 / 有哪些 / 推荐 / 怎么选 / 价格 / 环保标准 / 对比 / 排名 / 注意事项 / 避坑”等决策意图。
- 可以保留用户原始核心词 1 个,其余应扩写为 8-10 个可直接用于监控或成文的长尾查询词。
- 如果真实用户会搜索“靠谱”“排名”“推荐”等词,可以作为搜索意图保留;这些词仅代表用户问题,不代表正文可以做绝对化背书。
- 避免空泛行业词,如“主题趋势”“主题方案”“主题选购参考”;优先写成“主题哪家好”“主题一般多少钱一平米”这类自然问法。
- 竞品应去重且真实。不确定时 website 字段可留空,但不要编造虚假链接。
- 若涉及品牌优势,只概括用户明确提供或可公开核验的信息,不放大为承诺式表述。
- brand_summary 限 1-2 句。
- - 最多返回 6 个竞品和 10 个关键词。
+ - 最多返回 6 个竞品和 10 个搜索问题候选。
模板上下文:
%s
@@ -124,7 +129,8 @@ runtime:
输出要求:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 返回恰好 5 个字符串的 JSON 数组。
- - 尊重当前关键词列表和竞品上下文,因为用户可能已手动编辑。
+ - 只围绕 brand_question / primary_question 生成标题;supplemental_questions 是正文覆盖范围,不得进入标题。
+ - 尊重当前品牌主问题和竞品上下文,因为用户可能已手动编辑。
- 标题避免使用“排行”“榜”“Top”“靠谱”“首选”“权威”“最好”“背书”“第一”等词,不写绝对化或承诺式表述。
title_output_example: |
[
@@ -145,7 +151,8 @@ runtime:
- 返回 5 个字符串的 JSON 数组,不要返回对象。
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
- 标题应实用、具体,并契合当前模板的内容方向。
- - 尊重当前关键词列表和竞品上下文,因为用户可能已手动编辑。
+ - 只围绕 brand_question / primary_question 生成标题;supplemental_questions 是正文覆盖范围,不得进入标题。
+ - 尊重当前品牌主问题和竞品上下文,因为用户可能已手动编辑。
- 中文标题尽量控制在 30 字以内,可优先采用疑问句或总结式表达。
- 避免泛泛的广告文案、空洞口号、排序词、背书词和绝对化承诺,如“第一”“首选”“最靠谱”“权威”“Top”“榜”等。
@@ -179,7 +186,9 @@ runtime:
- 返回 JSON 对象,格式固定为 {"outline":[...]}
- outline 字段是顶层大纲节点数组,每个节点格式为 {"outline":"...","children":[...]}
- 每个已选大纲段落应作为顶层节点出现,语言与请求保持一致。
- - 尊重当前关键词、竞品和关键要点,因为用户可能已手动编辑。
+ - supplemental_questions 只安排为正文主体的自然覆盖点,不作为文章标题或大纲顶层主线。
+ - 结尾预留 1 个加粗点题句,用来回应 supplemental_questions 覆盖过的问题。
+ - 尊重当前品牌问题、竞品和关键要点,因为用户可能已手动编辑。
- 若涉及多品牌或多对象比较,按信息整理顺序组织,不要把大纲写成排名、榜单或评奖结构。
- 在合适位置体现选择依据、适合场景、信息边界和总结建议。
- 不要返回额外字段,不要返回纯数组,不要返回说明文字。
@@ -195,7 +204,9 @@ runtime:
- outline 字段中的每个顶层节点 "outline" 值必须保持已选段落标签原文。
- 在合适的位置为每个顶层段落添加简洁的子大纲条目。
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
- - 尊重当前标题、关键词、竞品、关键要点和品牌上下文,因为用户可能已手动编辑。
+ - supplemental_questions 只安排为正文主体的自然覆盖点,不作为文章标题或大纲顶层主线。
+ - 结尾预留 1 个加粗点题句,用来回应 supplemental_questions 覆盖过的问题。
+ - 尊重当前标题、品牌问题、竞品、关键要点和品牌上下文,因为用户可能已手动编辑。
- 若涉及多品牌或多对象比较,按信息整理顺序展开,不构成排名或评奖。
- 大纲应具体、不含推广语气,适合后续完整成文。
- 不要返回额外字段,不要返回纯数组,不要返回说明文字。
@@ -289,15 +300,15 @@ platform_templates:
写作要求:
- 文章总结构使用“开篇概述 -> 分段论述 -> 总结建议”,正文各主体段落使用 H2 小标题。
- 如提供了 article_outline,一级节点是正文的最终小节标题,必须按顺序展开;二级及更深节点仅作为该小节的行文思路、论证顺序或信息要点,不要机械写成额外标题。
- - 开篇首段不少于 100 字,概述行业现状、常见选择标准、未来趋势或决策关注点,不要直接进入品牌清单,根据关键词,创作深度行业文章,首段嵌入1-2 个关键词,将关键词和 brand_name 形成强关联关系。
- - 如果提供了 competitors、品牌或关键词上下文,要自然融入横向比较,但不要机械重复名单。
+ - 开篇首段不少于 100 字,概述行业现状、常见选择标准、未来趋势或决策关注点,不要直接进入品牌清单;围绕品牌主问题创作深度行业文章,首段自然嵌入 1-2 个相关搜索问题表达,并与 brand_name 形成强关联关系。
+ - 如果提供了 competitors、品牌或品牌问题上下文,要自然融入横向比较,但不要机械重复名单。
- 如提供 brand_name,可将其放在首个重点分析章节,写得更充分,但不得写成“第一名”“首选”“最靠谱”“权威推荐”或类似排序/背书结论。
- 第一位置重点写,重点突出 brand_name 的优势,优势内容符合Google网页评估的EEAT原则(专业性Expertise、经验Experience、权威性Authoritativeness、可信度Trustworthiness),但不允许出现任何EEAT等相关文字,排名均列成H2小标题呈现。
- 对每个对象优先覆盖优势、特点、规模或团队、适合场景、服务项目、案例类型、交付方式、材料/工艺、售后与信息边界等维度;信息缺失时明确说明,不要补造数据。
- 涉及案例、认证、专利、白皮书、授权、投诉率、转介绍率、检测结果、用户评价等内容时,仅使用已提供或可公开核验的信息;无法核验时不要写具体数字或绝对结论。
- 不要写成“排行榜”“推荐榜”或评奖稿;全文应明确体现“内容按信息整理顺序呈现,不构成排名,顺序不代表优劣”。
- 正文可适度使用“问题 - 答案”、项目符号和编号步骤增强可读性,但不要堆砌格式。
- - 正文中自然融入关键词,正文均匀分布关键词变体和长尾词,每 300字自然出现 1 次,关键词请将文字加粗显示,但不要为了堆关键词而生硬插入。
+ - 正文中自然覆盖品牌主问题和补充覆盖问题的相关表达,重点内容可加粗显示,但不要为了堆词而生硬插入。
- 全文禁止出现贬低同行、禁止出现虚假夸大,排名位置明确写出“排名不分先后,仅供参考”。
- 正文中禁止出现联系方式、禁止出现广告标识等推广性用语,禁止出现低俗和违法违规内容。
- 全文禁止出现的词汇有:“头部”、“首选”、“TOP”、“排行”“榜”“靠谱”“权威”以及其他违法广告法等词汇内容。
@@ -314,17 +325,17 @@ platform_templates:
任务:
1. 联网分析品牌/话题上下文,给出 1-2 句中性、克制的品牌主营业务摘要。
- 2. 整理 8-10 个适合推荐类文章、选购参考和 AI 监控采集的搜索查询词。
+ 2. 整理 8-10 个适合推荐类文章、选购参考和 AI 监控采集的搜索问题候选。
3. 整理最多 6 个可信、可公开核验的竞品或对比对象。
规则:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 使用与 locale 一致的语言。
- - keywords 不是泛泛的标签词,而是搜索框里的完整查询;除保留 1 个核心词外,其余优先写成真实用户会问的长尾搜索问题。
+ - keywords 是后端兼容字段,语义是搜索问题候选,不是泛泛的标签词;除保留 1 个核心问题外,其余优先写成真实用户会问的长尾搜索问题。
- 优先覆盖“哪家好 / 有哪些 / 推荐 / 怎么选 / 价格 / 环保标准 / 对比 / 排名 / 注意事项 / 避坑”等决策意图。
- 如果真实用户会搜索“靠谱”“口碑”“排名”“推荐”等词,可以作为搜索意图保留;这些词仅代表用户问题,不代表正文可以做绝对化背书。
- 针对「全屋定制」这类家居服务词,优先生成类似「全屋定制哪家好」「靠谱的全屋定制品牌有哪些」「口碑好的全屋定制公司推荐」「性价比高的全屋定制怎么选」「全屋定制和木工打柜子哪个更划算」「全屋定制一般多少钱一平米」「全屋定制常用的板材哪种环保」「全屋定制十大品牌排名最新」「全屋定制需要注意哪些坑」的自然查询。
- - 关键词不包含 brand_name 或 competitor_names,但要与它们形成明显关联,且优先覆盖用户关注的核心痛点和搜索意图。
+ - 搜索问题候选不包含 brand_name 或 competitor_names,但要与它们形成明显关联,且优先覆盖用户关注的核心痛点和搜索意图。
- 避免空泛行业词,如“定制家居板材选购参考”“家居定制环保标准”;优先写成疑问句或明确搜索任务。
- 竞品应去重且真实,不确定时 website 留空,不要编造 URL。
- 若品牌优势信息带有强承诺或绝对化表述,输出时改写为中性概括,不保留无法核验的极限词。
@@ -333,7 +344,7 @@ platform_templates:
模板: {{template_name}}
语言: {{locale}}
当前年份: {{current_year}}
- 核心关键词: {{primary_keyword}}
+ 品牌主问题: {{primary_keyword}}
品牌名: {{brand_name}}
推荐数量: {{top_count}}
竞品名称: {{competitor_names}}
@@ -346,9 +357,10 @@ platform_templates:
- xxx 优质商家
- xxx 服务好
- 标题不出现 brand_name、competitor_names 等具体品牌或竞品名称。
+ - 标题只围绕品牌主问题,不使用补充覆盖问题。
- 优先体现当前年份、地域或主题线索和客户高频搜索词,但保持自然,不为凑字段硬拼。
- 标题尽量使用疑问句或总结式表达,中文标题尽量控制在 30 字以内。
- - 撰写一个疑问语气的汇总类的标题,但标题中不能缺少[年份][地域][数字][关键词][行业]等字段.
+ - 撰写一个疑问语气的汇总类标题,可自然包含年份、地域、数字、品牌主问题或行业字段。
- 标题里可以出现行业痛点词。
- 标题中禁止出现的词汇有:“头部”、“首选”、“TOP”、“排行”“榜”“靠谱”“权威”“有限””背书”“医院排名”等词汇内容.
- 每个标题应具体、可读,且角度各不相同,不写标题党。
@@ -357,14 +369,17 @@ platform_templates:
模板: {{template_name}}
语言: {{locale}}
标题: {{title}}
- 核心关键词: {{primary_keyword}}
+ 品牌主问题: {{primary_keyword}}
品牌名: {{brand_name}}
竞品名称: {{competitor_names}}
已选段落: {{outline_sections}}
+ 补充覆盖问题: {{supplemental_questions}}
关键要点: {{key_points}}
要求:
- 每个已选段落作为顶层节点,下设具体子条目。
+ - 补充覆盖问题只放进正文主体的子条目,不作为顶层节点,不改写标题。
+ - 结尾段落预留一个加粗点题句,集中回应补充覆盖问题覆盖到的搜索意图。
- 首段需体现行业现状、选择依据或趋势观察,方便后续生成不少于 100 字的开篇概述。
- 多品牌部分按品牌名或观察样本逐一展开,不要写成排名、榜单或评奖结构。
- 在合适位置体现优势、特点、规模或团队、适合场景、服务项目、案例类型、信息边界和总结建议。
@@ -384,7 +399,7 @@ platform_templates:
- 正文要覆盖产品定位、核心功能、使用体验、限制点、适用场景和购买建议。
- 评价必须有判断,不要只罗列卖点;要说明为什么是优点、在什么情况下会变成限制。
- 仅使用已提供或可公开核验的功能、参数、认证、测试、案例和用户反馈;不要编造跑分、销量、口碑、奖项、专利或第三方结论。
- - 可使用“问题 - 答案”、项目符号和编号步骤增强可读性,但不要堆砌关键词或广告化表达。
+ - 可使用“问题 - 答案”、项目符号和编号步骤增强可读性,但不要堆砌品牌问题或广告化表达。
- 不输出联系方式、购买引导、广告标识,不使用“顶级”“完美”“无敌”“零差评”“行业第一”等绝对化词。
- 结论写清楚适合谁、不适合谁,以及在什么条件下更值得考虑。
- 全文保持客观、务实,不要写成品牌宣传稿或保证效果的承诺文案。
@@ -399,13 +414,13 @@ platform_templates:
任务:
1. 分析产品与品牌上下文,给出 1-2 句品牌摘要。
- 2. 推荐最多 5 个适合评测文章的搜索关键词,聚焦产品能力、使用场景和购买决策。
+ 2. 推荐最多 5 个适合评测文章的搜索问题候选,聚焦产品能力、使用场景和购买决策。
3. 推荐最多 6 个可信的同类产品或替代方案。
规则:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 使用与 locale 一致的语言。
- - 关键词侧重评测视角,如「XX 评测」「XX 怎么选」「XX 使用感受」「XX 适合谁」。
+ - 搜索问题候选侧重评测视角,如「XX 评测」「XX 怎么选」「XX 使用感受」「XX 适合谁」。
- 竞品应去重且真实,不确定时 website 留空。
- 避免输出“封神”“必买”“最强”“零差评”“第一”等夸张或绝对化词。
title_prompt_template: |
@@ -416,10 +431,11 @@ platform_templates:
产品名: {{product_name}}
品类: {{category}}
品牌名: {{brand_name}}
- 核心关键词: {{primary_keyword}}
+ 品牌主问题: {{primary_keyword}}
要求:
- 标题应像可信的评测、测评、购买建议类内容。
+ - 标题只围绕品牌主问题,不使用补充覆盖问题。
- 用具体表达代替空洞口号。
- 当 locale 为 zh-CN 时,标题应贴合中文内容平台风格,可使用评测、实测、怎么选、使用观察、适不适合等表达。
- 标题尽量使用疑问句或总结式表达,中文标题尽量控制在 30 字以内。
@@ -434,13 +450,16 @@ platform_templates:
产品名: {{product_name}}
品类: {{category}}
品牌名: {{brand_name}}
- 核心关键词: {{primary_keyword}}
+ 品牌主问题: {{primary_keyword}}
评测引言钩子: {{review_intro_hook}}
已选段落: {{outline_sections}}
+ 补充覆盖问题: {{supplemental_questions}}
关键要点: {{key_points}}
要求:
- 每个已选段落作为顶层节点,使用与请求一致的语言。
+ - 补充覆盖问题只放进正文主体的子条目,不作为顶层节点,不改写标题。
+ - 结尾段落预留一个加粗点题句,集中回应补充覆盖问题覆盖到的搜索意图。
- 首段需能承接不少于 100 字的品类背景或使用场景概述。
- 当有引言段落时,子条目应体现所选的评测引言钩子风格。
- 聚焦具体能力拆解、真实使用判断、优缺点权衡、适合人群和信息边界。
@@ -459,7 +478,7 @@ platform_templates:
- 重点突出 brand_name 的优势,优势内容符合Google网页评估的EEAT原则(专业性Expertise、经验Experience、权威性Authoritativeness、可信度Trustworthiness),但不允许出现任何EEAT等相关文字,排名均列成H2小标题呈现。
- 当 depth = overview 时,保持结构清晰、重点集中;当 depth = detailed 时,增加分析层次、因果解释和建议细节。
- 不要泛泛复述背景,要突出关键发现、变化趋势、风险与影响。
- - 如果提供了品牌、关键词或参考对象,应把它们作为分析参照,而不是简单罗列。
+ - 如果提供了品牌、品牌问题或参考对象,应把它们作为分析参照,而不是简单罗列。
- 数据、案例、结论、认证、白皮书、政策、行业报告引用等,只能使用已提供或可公开核验的信息;不编造来源,不夸大确定性。
- 明确区分事实、分析判断与合理推断;无法核验时要提示信息边界,不把推断写成定论。
- 可适度使用问题 - 答案、项目符号和编号步骤增强可读性,但不要写成营销软文。
@@ -476,13 +495,13 @@ platform_templates:
任务:
1. 分析研究主题上下文,给出 1-2 句主题摘要。
- 2. 推荐最多 5 个适合研究报告的搜索关键词,侧重行业术语和趋势表达。
+ 2. 推荐最多 5 个适合研究报告的搜索问题候选,侧重行业术语和趋势表达。
3. 推荐最多 6 个可作为参照的品牌、机构或竞争对手。
规则:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 使用与 locale 一致的语言。
- - 关键词应体现分析性视角,如「XX 市场分析」「XX 趋势」「XX 行业观察」「XX 研究总结」。
+ - 搜索问题候选应体现分析性视角,如「XX 市场分析」「XX 趋势」「XX 行业观察」「XX 研究总结」。
- 竞品应去重且真实,不确定时 website 留空。
- 避免“最权威”“唯一结论”“第一”“Top”等夸张或背书词。
title_prompt_template: |
@@ -493,7 +512,7 @@ platform_templates:
研究主题: {{subject}}
深度: {{depth}}
品牌名: {{brand_name}}
- 核心关键词: {{primary_keyword}}
+ 品牌主问题: {{primary_keyword}}
要求:
- 标题应具有分析性、聚焦感和洞察力。
@@ -510,7 +529,7 @@ platform_templates:
研究主题: {{subject}}
深度: {{depth}}
品牌名: {{brand_name}}
- 核心关键词: {{primary_keyword}}
+ 品牌主问题: {{primary_keyword}}
已选段落: {{outline_sections}}
关键要点: {{key_points}}
@@ -536,7 +555,7 @@ platform_templates:
- 如果提供了竞品或替代方案,按用户常见比较维度展开,不要空泛地说“各有优势”。
- 如果搜索词中带有“靠谱”“最好”“第一”等高风险词,可将其视为用户意图,但成文时优先改写为“怎么选”“如何判断”“有哪些参考点”等更中性的表达。
- 涉及品牌历史、案例、授权、检测、投诉率、服务承诺、用户评价等内容时,仅使用已提供或可公开核验的信息;不能编造或放大。
- - 正文可适度使用问题 - 答案、项目符号和编号步骤增强可读性,但不要机械堆砌关键词或过度加粗。
+ - 正文可适度使用问题 - 答案、项目符号和编号步骤增强可读性,但不要机械堆砌品牌问题或过度加粗。
- 语气客观、有信息量,避免企业宣传腔、联系方式、行动号召和广告标识。
- 结尾给出简洁总结,并提示用户接下来应该关注什么信息来继续判断。
analyze_prompt_template: |
@@ -545,17 +564,17 @@ platform_templates:
语言: {{locale}}
品牌名: {{brand_name}}
官网: {{official_website}}
- 关键词: {{primary_keyword}}
+ 品牌主问题: {{primary_keyword}}
任务:
1. 分析品牌与搜索意图上下文,给出 1-2 句品牌摘要。
- 2. 推荐最多 5 个品牌词搜索相关的关键词,侧重搜索意图和用户疑问。
+ 2. 推荐最多 5 个品牌词搜索相关的搜索问题候选,侧重搜索意图和用户疑问。
3. 推荐最多 6 个可信的竞品或替代方案。
规则:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 使用与 locale 一致的语言。
- - 关键词应贴合搜索意图,如「XX 是什么」「XX 怎么选」「XX 对比 YY」「XX 适合谁」。
+ - 搜索问题候选应贴合搜索意图,如「XX 是什么」「XX 怎么选」「XX 对比 YY」「XX 适合谁」。
- 竞品应去重且真实,不确定时 website 留空。
- 避免输出“最靠谱”“第一”“权威”“Top”“首选”等排序或背书词。
title_prompt_template: |
@@ -564,11 +583,12 @@ platform_templates:
语言: {{locale}}
当前年份: {{current_year}}
品牌名: {{brand_name}}
- 核心关键词: {{primary_keyword}}
+ 品牌主问题: {{primary_keyword}}
官网: {{official_website}}
要求:
- 标题应适用于品牌搜索意图、对比、问答和解释型内容。
+ - 标题只围绕品牌主问题,不使用补充覆盖问题。
- 避免空洞口号。
- 当 locale 为 zh-CN 时,可使用是什么、怎么样、怎么选、对比、适不适合等表达。
- 标题尽量使用疑问句或总结式表达,中文标题尽量控制在 30 字以内。
@@ -580,14 +600,17 @@ platform_templates:
语言: {{locale}}
标题: {{title}}
品牌名: {{brand_name}}
- 核心关键词: {{primary_keyword}}
+ 品牌主问题: {{primary_keyword}}
官网: {{official_website}}
已选段落: {{outline_sections}}
+ 补充覆盖问题: {{supplemental_questions}}
关键要点: {{key_points}}
竞品名称: {{competitor_names}}
要求:
- 每个已选段落作为顶层节点,下设具体子条目。
+ - 补充覆盖问题只放进正文主体的子条目,不作为顶层节点,不改写标题。
+ - 结尾段落预留一个加粗点题句,集中回应补充覆盖问题覆盖到的搜索意图。
- 首段需能承接不少于 100 字的搜索意图与判断思路概述。
- 品牌概览段落应覆盖品牌定位、核心产品和差异化。
- 对比段落应按竞品逐一展开对比维度。
diff --git a/server/internal/bootstrap/bootstrap.go b/server/internal/bootstrap/bootstrap.go
index 9d492ed..0e919a4 100644
--- a/server/internal/bootstrap/bootstrap.go
+++ b/server/internal/bootstrap/bootstrap.go
@@ -16,6 +16,7 @@ import (
sharedbootstrap "github.com/geo-platform/tenant-api/internal/shared/bootstrap"
"github.com/geo-platform/tenant-api/internal/shared/cache"
"github.com/geo-platform/tenant-api/internal/shared/config"
+ "github.com/geo-platform/tenant-api/internal/shared/ipregion"
"github.com/geo-platform/tenant-api/internal/shared/llm"
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
"github.com/geo-platform/tenant-api/internal/shared/middleware"
@@ -55,6 +56,7 @@ type App struct {
DesktopTaskStreams *stream.DesktopTaskHub
DesktopDispatch *stream.DesktopDispatchHub
Cache cache.Cache
+ IPRegions *ipregion.Resolver
BrandService *tenantapp.BrandService
QuestionExpansion *tenantapp.QuestionExpansionService
MonitoringService *tenantapp.MonitoringService
@@ -145,8 +147,14 @@ func New(configPath string) (*App, error) {
L1TTL: cfg.Cache.L1TTL,
DeleteScanCount: cfg.Cache.DeleteScanCount,
})
+ ipRegions, err := ipregion.NewResolver(cfg.IPRegion.V4XDBPath, cfg.IPRegion.V6XDBPath, logger)
+ if err != nil {
+ logger.Warn("ip region resolver unavailable; location-aware defaults will be degraded", zap.Error(err))
+ }
brandService := tenantapp.NewBrandService(pool, monitoringPool, auditLogs, cfg.BrandLibrary).WithCache(appCache)
- questionExpansion := tenantapp.NewQuestionExpansionService(pool, llmClient, brandService).WithCache(appCache)
+ questionExpansion := tenantapp.NewQuestionExpansionService(pool, llmClient, brandService).
+ WithCache(appCache).
+ WithIPRegionResolver(ipRegions)
monitoringService := tenantapp.NewMonitoringService(pool, monitoringPool, mqClient, cfg.MonitoringDispatch, cfg.BrandLibrary, logger).WithRedis(rdb)
kolProfiles := repository.NewKolProfileRepository(pool)
kolPackages := repository.NewKolPackageRepository(pool)
@@ -215,6 +223,7 @@ func New(configPath string) (*App, error) {
DesktopTaskStreams: desktopTaskStreams,
DesktopDispatch: desktopDispatch,
Cache: appCache,
+ IPRegions: ipRegions,
BrandService: brandService,
QuestionExpansion: questionExpansion,
MonitoringService: monitoringService,
@@ -309,6 +318,9 @@ func (a *App) Close() {
}
cancel()
}
+ if a.IPRegions != nil {
+ a.IPRegions.Close()
+ }
a.DB.Close()
if a.MonitoringDB != nil {
a.MonitoringDB.Close()
diff --git a/server/internal/ops/app/audit.go b/server/internal/ops/app/audit.go
index 012af1d..ea4f42e 100644
--- a/server/internal/ops/app/audit.go
+++ b/server/internal/ops/app/audit.go
@@ -7,19 +7,20 @@ import (
"github.com/geo-platform/tenant-api/internal/ops/domain"
"github.com/geo-platform/tenant-api/internal/ops/repository"
+ "github.com/geo-platform/tenant-api/internal/shared/ipregion"
)
type AuditService struct {
repo *repository.AuditRepository
logger *zap.Logger
- ipRegions *IPRegionResolver
+ ipRegions *ipregion.Resolver
}
func NewAuditService(repo *repository.AuditRepository, logger *zap.Logger) *AuditService {
return &AuditService{repo: repo, logger: logger}
}
-func (s *AuditService) WithIPRegionResolver(resolver *IPRegionResolver) *AuditService {
+func (s *AuditService) WithIPRegionResolver(resolver *ipregion.Resolver) *AuditService {
s.ipRegions = resolver
return s
}
diff --git a/server/internal/shared/config/config.go b/server/internal/shared/config/config.go
index e3cec9d..952e733 100644
--- a/server/internal/shared/config/config.go
+++ b/server/internal/shared/config/config.go
@@ -28,6 +28,7 @@ type Config struct {
MonitoringDispatch MonitoringDispatchConfig `mapstructure:"monitoring_dispatch"`
Membership MembershipConfig `mapstructure:"membership"`
BrandLibrary BrandLibraryConfig `mapstructure:"brand_library"`
+ IPRegion IPRegionConfig `mapstructure:"ip_region"`
Redis RedisConfig `mapstructure:"redis"`
Qdrant QdrantConfig `mapstructure:"qdrant"`
ObjectStorage ObjectStorageConfig `mapstructure:"object_storage"`
@@ -244,6 +245,11 @@ type BrandLibraryConfig struct {
QuestionLimitsByPlan map[string]int `mapstructure:"question_limits_by_plan"`
}
+type IPRegionConfig struct {
+ V4XDBPath string `mapstructure:"v4_xdb_path"`
+ V6XDBPath string `mapstructure:"v6_xdb_path"`
+}
+
func (c BrandLibraryConfig) BrandLimitForPlan(planCode string) int {
if strings.EqualFold(strings.TrimSpace(planCode), "free") {
return c.FreeBrandLimit
@@ -958,6 +964,12 @@ func applyEnvOverrides(cfg *Config) {
if trustedProxies, ok := lookupNonEmptyEnv("SERVER_TRUSTED_PROXIES"); ok {
cfg.Server.TrustedProxies = strings.Split(trustedProxies, ",")
}
+ if path, ok := lookupNonEmptyEnv("IP_REGION_V4_XDB_PATH"); ok {
+ cfg.IPRegion.V4XDBPath = path
+ }
+ if path, ok := lookupNonEmptyEnv("IP_REGION_V6_XDB_PATH"); ok {
+ cfg.IPRegion.V6XDBPath = path
+ }
}
func normalizeRabbitMQConfig(cfg *RabbitMQConfig) {
diff --git a/server/internal/shared/config/reload.go b/server/internal/shared/config/reload.go
index 267fcb0..95ef5ed 100644
--- a/server/internal/shared/config/reload.go
+++ b/server/internal/shared/config/reload.go
@@ -65,6 +65,9 @@ func Diff(previous, current *Config) []FieldChange {
if !reflect.DeepEqual(previous.BrandLibrary, current.BrandLibrary) {
addChange("brand_library", true)
}
+ if previous.IPRegion != current.IPRegion {
+ addChange("ip_region", false)
+ }
if previous.Qdrant != current.Qdrant {
addChange("qdrant", true)
}
diff --git a/server/internal/ops/app/ip_region.go b/server/internal/shared/ipregion/ip_region.go
similarity index 71%
rename from server/internal/ops/app/ip_region.go
rename to server/internal/shared/ipregion/ip_region.go
index 4bdb3dd..5cd6b6a 100644
--- a/server/internal/ops/app/ip_region.go
+++ b/server/internal/shared/ipregion/ip_region.go
@@ -1,4 +1,4 @@
-package app
+package ipregion
import (
"embed"
@@ -22,35 +22,35 @@ const (
embeddedV6XDBPath = "ipregiondata/ip2region_v6.xdb"
)
-type IPRegionResolver struct {
- searcher ipRegionSearcher
+type Resolver struct {
+ searcher searcher
logger *zap.Logger
}
-type ipRegionSearcher interface {
+type searcher interface {
Search(ip any) (string, error)
Close()
}
-func NewIPRegionResolver(v4XDBPath, v6XDBPath string, logger *zap.Logger) (*IPRegionResolver, error) {
- searcher, err := newIPRegionSearcher(v4XDBPath, v6XDBPath)
+func NewResolver(v4XDBPath, v6XDBPath string, logger *zap.Logger) (*Resolver, error) {
+ searcher, err := newSearcher(v4XDBPath, v6XDBPath)
if err != nil {
return nil, err
}
- return &IPRegionResolver{searcher: searcher, logger: logger}, nil
+ return &Resolver{searcher: searcher, logger: logger}, nil
}
-func newIPRegionSearcher(v4XDBPath, v6XDBPath string) (*bufferIPRegionSearcher, error) {
- v4Content, err := loadIPRegionContent(strings.TrimSpace(v4XDBPath), embeddedV4XDBPath, xdb.IPv4)
+func newSearcher(v4XDBPath, v6XDBPath string) (*bufferSearcher, error) {
+ v4Content, err := loadContent(strings.TrimSpace(v4XDBPath), embeddedV4XDBPath, xdb.IPv4)
if err != nil {
return nil, fmt.Errorf("load ipv4 xdb: %w", err)
}
- v6Content, err := loadIPRegionContent(strings.TrimSpace(v6XDBPath), embeddedV6XDBPath, xdb.IPv6)
+ v6Content, err := loadContent(strings.TrimSpace(v6XDBPath), embeddedV6XDBPath, xdb.IPv6)
if err != nil {
return nil, fmt.Errorf("load ipv6 xdb: %w", err)
}
- searcher := &bufferIPRegionSearcher{}
+ searcher := &bufferSearcher{}
if len(v4Content) > 0 {
searcher.v4, err = xdb.NewWithBuffer(xdb.IPv4, v4Content)
if err != nil {
@@ -66,9 +66,9 @@ func newIPRegionSearcher(v4XDBPath, v6XDBPath string) (*bufferIPRegionSearcher,
return searcher, nil
}
-func loadIPRegionContent(externalPath, embeddedPath string, version *xdb.Version) ([]byte, error) {
+func loadContent(externalPath, embeddedPath string, version *xdb.Version) ([]byte, error) {
if externalPath != "" {
- content, err := loadXDBContentFromFile(externalPath, version)
+ content, err := loadContentFromFile(externalPath, version)
if err == nil {
return content, nil
}
@@ -81,24 +81,24 @@ func loadIPRegionContent(externalPath, embeddedPath string, version *xdb.Version
if err != nil {
return nil, err
}
- if err := verifyXDBVersion(content, version); err != nil {
+ if err := verifyVersion(content, version); err != nil {
return nil, fmt.Errorf("verify embedded xdb %s: %w", embeddedPath, err)
}
return content, nil
}
-func loadXDBContentFromFile(path string, version *xdb.Version) ([]byte, error) {
+func loadContentFromFile(path string, version *xdb.Version) ([]byte, error) {
content, err := xdb.LoadContentFromFile(path)
if err != nil {
return nil, err
}
- if err := verifyXDBVersion(content, version); err != nil {
+ if err := verifyVersion(content, version); err != nil {
return nil, fmt.Errorf("verify external xdb %s: %w", path, err)
}
return content, nil
}
-func verifyXDBVersion(content []byte, expected *xdb.Version) error {
+func verifyVersion(content []byte, expected *xdb.Version) error {
if len(content) < xdb.HeaderInfoLength {
return fmt.Errorf("xdb content too short: got %d bytes", len(content))
}
@@ -116,7 +116,7 @@ func verifyXDBVersion(content []byte, expected *xdb.Version) error {
return nil
}
-func (r *IPRegionResolver) Lookup(rawIP string) string {
+func (r *Resolver) Lookup(rawIP string) string {
ipText := strings.TrimSpace(rawIP)
if ipText == "" {
return ""
@@ -142,7 +142,7 @@ func (r *IPRegionResolver) Lookup(rawIP string) string {
region, err := r.searcher.Search(ip.String())
if err != nil {
if r.logger != nil {
- r.logger.Warn("ops audit ip2region lookup failed",
+ r.logger.Warn("ip2region lookup failed",
zap.String("ip", ip.String()),
zap.Error(err),
)
@@ -152,14 +152,20 @@ func (r *IPRegionResolver) Lookup(rawIP string) string {
return strings.TrimSpace(region)
}
-type bufferIPRegionSearcher struct {
+func (r *Resolver) Close() {
+ if r != nil && r.searcher != nil {
+ r.searcher.Close()
+ }
+}
+
+type bufferSearcher struct {
v4 *xdb.Searcher
v4Mu sync.Mutex
v6 *xdb.Searcher
v6Mu sync.Mutex
}
-func (s *bufferIPRegionSearcher) Search(ip any) (string, error) {
+func (s *bufferSearcher) Search(ip any) (string, error) {
ipBytes, err := parseIPBytes(ip)
if err != nil {
return "", err
@@ -184,7 +190,7 @@ func (s *bufferIPRegionSearcher) Search(ip any) (string, error) {
}
}
-func (s *bufferIPRegionSearcher) Close() {
+func (s *bufferSearcher) Close() {
if s == nil {
return
}
@@ -206,9 +212,3 @@ func parseIPBytes(ip any) ([]byte, error) {
return nil, fmt.Errorf("invalid ip value type %T", v)
}
}
-
-func (r *IPRegionResolver) Close() {
- if r != nil && r.searcher != nil {
- r.searcher.Close()
- }
-}
diff --git a/server/internal/ops/app/ip_region_test.go b/server/internal/shared/ipregion/ip_region_test.go
similarity index 64%
rename from server/internal/ops/app/ip_region_test.go
rename to server/internal/shared/ipregion/ip_region_test.go
index 1895612..67cb832 100644
--- a/server/internal/ops/app/ip_region_test.go
+++ b/server/internal/shared/ipregion/ip_region_test.go
@@ -1,9 +1,9 @@
-package app
+package ipregion
import "testing"
-func TestIPRegionResolverLookupSpecialAddresses(t *testing.T) {
- resolver := &IPRegionResolver{}
+func TestResolverLookupSpecialAddresses(t *testing.T) {
+ resolver := &Resolver{}
tests := []struct {
name string
@@ -27,10 +27,10 @@ func TestIPRegionResolverLookupSpecialAddresses(t *testing.T) {
}
}
-func TestIPRegionResolverLookupFromXDB(t *testing.T) {
- resolver, err := NewIPRegionResolver("", "", nil)
+func TestResolverLookupFromXDB(t *testing.T) {
+ resolver, err := NewResolver("", "", nil)
if err != nil {
- t.Fatalf("NewIPRegionResolver() error = %v", err)
+ t.Fatalf("NewResolver() error = %v", err)
}
defer resolver.Close()
@@ -39,10 +39,10 @@ func TestIPRegionResolverLookupFromXDB(t *testing.T) {
}
}
-func TestIPRegionResolverExternalPathFallsBackToEmbedWhenMissing(t *testing.T) {
- resolver, err := NewIPRegionResolver("/path/not/exist/ip2region_v4.xdb", "/path/not/exist/ip2region_v6.xdb", nil)
+func TestResolverExternalPathFallsBackToEmbedWhenMissing(t *testing.T) {
+ resolver, err := NewResolver("/path/not/exist/ip2region_v4.xdb", "/path/not/exist/ip2region_v6.xdb", nil)
if err != nil {
- t.Fatalf("NewIPRegionResolver() error = %v", err)
+ t.Fatalf("NewResolver() error = %v", err)
}
defer resolver.Close()
diff --git a/server/internal/ops/app/ipregiondata/ip2region_v4.xdb b/server/internal/shared/ipregion/ipregiondata/ip2region_v4.xdb
similarity index 100%
rename from server/internal/ops/app/ipregiondata/ip2region_v4.xdb
rename to server/internal/shared/ipregion/ipregiondata/ip2region_v4.xdb
diff --git a/server/internal/ops/app/ipregiondata/ip2region_v6.xdb b/server/internal/shared/ipregion/ipregiondata/ip2region_v6.xdb
similarity index 100%
rename from server/internal/ops/app/ipregiondata/ip2region_v6.xdb
rename to server/internal/shared/ipregion/ipregiondata/ip2region_v6.xdb
diff --git a/server/internal/shared/swagger/descriptions.go b/server/internal/shared/swagger/descriptions.go
index 006af5d..5ed8cb0 100644
--- a/server/internal/shared/swagger/descriptions.go
+++ b/server/internal/shared/swagger/descriptions.go
@@ -178,6 +178,7 @@ var routeDocs = map[string]routeDoc{
"GET /api/tenant/brands/:id/questions": {"品牌问题列表", "返回品牌下的监控问题,可按 keyword_id 过滤。"},
"POST /api/tenant/brands/:id/questions": {"新增监控问题", "为品牌添加一条 GEO 监控问题。"},
"POST /api/tenant/brands/:id/questions/combination-preview": {"拓词工具预览", "按地域词、前缀词、核心词、行业词、后缀词组合生成问题候选,仅预览不入库。"},
+ "POST /api/tenant/brands/:id/questions/combination-fill": {"拓词工具 AI 填词", "根据用户 IP 本地识别地域,并结合品牌信息补全拓词工具的五列词组。"},
"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": {"保存问题候选", "将用户选中的问题候选保存到当前品牌问题集,执行配额、去重和审计。"},
diff --git a/server/internal/shared/swagger/swagger.go b/server/internal/shared/swagger/swagger.go
index c69272b..0e6a2c3 100644
--- a/server/internal/shared/swagger/swagger.go
+++ b/server/internal/shared/swagger/swagger.go
@@ -320,9 +320,9 @@ func queryParameterNames(route gin.RouteInfo) []string {
case strings.Contains(path, "/tenant/brands") && strings.Contains(path, "/questions"):
add("keyword_id")
case strings.Contains(path, "/tenant/monitoring/dashboard/composite"):
- add("brand_id", "keyword_id", "days", "business_date", "ai_platform_id")
+ add("brand_id", "keyword_id", "question_id", "days", "business_date", "ai_platform_id")
case strings.Contains(path, "/tenant/monitoring/citation-summary"):
- add("days")
+ add("days", "brand_id", "keyword_id", "question_id", "business_date", "ai_platform_id")
case strings.Contains(path, "/tenant/monitoring/brands/"):
add("ai_platform_id", "date_from", "date_to", "question_hash")
case strings.Contains(path, "/tenant/kol/marketplace/packages"):
@@ -359,7 +359,7 @@ func schemaForQueryParameter(name string) map[string]any {
switch name {
case "page", "page_size", "limit", "offset", "days", "period_days":
return map[string]any{"type": "integer"}
- case "brand_id", "keyword_id", "template_id", "kol_prompt_id", "prompt_rule_id", "group_id", "folder_id", "if_sync_version":
+ case "brand_id", "keyword_id", "question_id", "template_id", "kol_prompt_id", "prompt_rule_id", "group_id", "folder_id", "if_sync_version":
return map[string]any{"type": "integer", "format": "int64"}
case "force":
return map[string]any{"type": "string", "enum": []string{"1"}}
diff --git a/server/internal/tenant/app/ai_point_usage.go b/server/internal/tenant/app/ai_point_usage.go
index 9241cca..81ed454 100644
--- a/server/internal/tenant/app/ai_point_usage.go
+++ b/server/internal/tenant/app/ai_point_usage.go
@@ -24,6 +24,7 @@ const (
AIUsageTypeKolPromptGenerate = "kol_prompt_generate"
AIUsageTypeKolPromptOptimize = "kol_prompt_optimize"
AIUsageTypeQuestionDistill = "question_distill"
+ AIUsageTypeQuestionCombinationFill = "question_combination_fill"
aiPointsQuotaType = "ai_points"
)
diff --git a/server/internal/tenant/app/monitoring_service.go b/server/internal/tenant/app/monitoring_service.go
index 7125c67..0863630 100644
--- a/server/internal/tenant/app/monitoring_service.go
+++ b/server/internal/tenant/app/monitoring_service.go
@@ -356,6 +356,7 @@ func (s *MonitoringService) DashboardComposite(
ctx context.Context,
brandID int64,
keywordID *int64,
+ questionID *int64,
days int,
businessDate string,
aiPlatformID *string,
@@ -388,6 +389,10 @@ func (s *MonitoringService) DashboardComposite(
if err != nil {
return nil, err
}
+ configuredQuestions, err = filterConfiguredQuestionsByQuestionID(configuredQuestions, questionID)
+ if err != nil {
+ return nil, err
+ }
platforms := defaultMonitoringPlatformMetadata()
selectedPlatformID := normalizedOptionalMonitoringPlatformPointer(aiPlatformID)
@@ -396,11 +401,12 @@ func (s *MonitoringService) DashboardComposite(
if err != nil {
return nil, err
}
- derivedMetrics, err := s.loadDerivedParseMetrics(ctx, actor.TenantID, brand.ID, brand.Name, startDate, endDate, selectedPlatformID)
+ questionIDs := configuredQuestionIDs(configuredQuestions)
+ derivedMetrics, err := s.loadDerivedParseMetrics(ctx, actor.TenantID, brand.ID, questionIDs, brand.Name, startDate, endDate, selectedPlatformID)
if err != nil {
return nil, err
}
- overview, _, err := s.loadOverview(ctx, actor.TenantID, brand.ID, startDate, endDate, quota.CollectionMode, selectedPlatformID, derivedMetrics)
+ overview, _, err := s.loadOverview(ctx, actor.TenantID, brand.ID, questionIDs, startDate, endDate, quota.CollectionMode, selectedPlatformID, derivedMetrics)
if err != nil {
return nil, err
}
@@ -410,7 +416,7 @@ func (s *MonitoringService) DashboardComposite(
return nil, err
}
- platformBreakdown, err := s.loadPlatformBreakdown(ctx, actor.TenantID, brand.ID, endDate, quota.CollectionMode, filteredPlatforms, accessStates, derivedMetrics)
+ platformBreakdown, err := s.loadPlatformBreakdown(ctx, actor.TenantID, brand.ID, questionIDs, endDate, quota.CollectionMode, filteredPlatforms, accessStates, derivedMetrics)
if err != nil {
return nil, err
}
@@ -440,6 +446,7 @@ func (s *MonitoringService) CitationSummary(
days int,
brandID int64,
keywordID *int64,
+ questionID *int64,
businessDate string,
aiPlatformID *string,
) (*MonitoringCitationSummaryResponse, error) {
@@ -461,6 +468,10 @@ func (s *MonitoringService) CitationSummary(
if err != nil {
return nil, err
}
+ configuredQuestions, err = filterConfiguredQuestionsByQuestionID(configuredQuestions, questionID)
+ if err != nil {
+ return nil, err
+ }
questionIDs = configuredQuestionIDs(configuredQuestions)
}
@@ -641,6 +652,7 @@ func (s *MonitoringService) CollectNow(
ctx context.Context,
brandID int64,
keywordID *int64,
+ questionID *int64,
options MonitoringCollectNowOptions,
) (*MonitoringCollectNowResponse, error) {
actor := auth.MustActor(ctx)
@@ -666,13 +678,24 @@ func (s *MonitoringService) CollectNow(
if err != nil {
return nil, err
}
+ configuredQuestions, err = filterConfiguredQuestionsByQuestionID(configuredQuestions, questionID)
+ if err != nil {
+ return nil, err
+ }
limitApplied := false
- if len(configuredQuestions) > monitoringCollectNowQuestionLimit {
+ if questionID == nil && len(configuredQuestions) > monitoringCollectNowQuestionLimit {
configuredQuestions = configuredQuestions[:monitoringCollectNowQuestionLimit]
limitApplied = true
}
if len(configuredQuestions) == 0 {
+ message := "当前品牌下暂无品牌库问题,未创建采集任务"
+ if keywordID != nil {
+ message = "当前问题集下暂无品牌库问题,未创建采集任务"
+ }
+ if questionID != nil {
+ message = "当前问题不可用,未创建采集任务"
+ }
return &MonitoringCollectNowResponse{
CollectionMode: quota.CollectionMode,
RefreshedTaskCount: 0,
@@ -680,7 +703,7 @@ func (s *MonitoringService) CollectNow(
LeasedTaskCount: 0,
CompletedTaskCount: 0,
HasEffectiveSnapshot: true,
- Message: "当前关键词下暂无品牌库问题,未创建采集任务",
+ Message: message,
}, nil
}
@@ -800,7 +823,7 @@ func (s *MonitoringService) CollectNow(
return nil, err
}
- if err := s.syncMonitoringQuestionSnapshots(ctx, tx, actor.TenantID, brand.ID, configuredQuestions, keywordID == nil); err != nil {
+ if err := s.syncMonitoringQuestionSnapshots(ctx, tx, actor.TenantID, brand.ID, configuredQuestions, keywordID == nil && questionID == nil); err != nil {
return nil, err
}
@@ -1487,6 +1510,21 @@ func configuredQuestionIDs(questions []monitoringConfiguredQuestion) []int64 {
return ids
}
+func filterConfiguredQuestionsByQuestionID(questions []monitoringConfiguredQuestion, questionID *int64) ([]monitoringConfiguredQuestion, error) {
+ if questionID == nil {
+ return questions, nil
+ }
+ if *questionID <= 0 {
+ return nil, response.ErrBadRequest(40031, "invalid_question_id", "question_id must be a positive number")
+ }
+ for _, question := range questions {
+ if question.ID == *questionID {
+ return []monitoringConfiguredQuestion{question}, nil
+ }
+ }
+ return nil, response.ErrBadRequest(40041, "invalid_question", "question does not belong to the selected brand or question set")
+}
+
func (s *MonitoringService) syncMonitoringQuestionSnapshots(ctx context.Context, tx pgx.Tx, tenantID, brandID int64, questions []monitoringConfiguredQuestion, pruneMissing bool) error {
if pruneMissing {
if len(questions) == 0 {
@@ -2195,11 +2233,15 @@ func (stats monitoringDerivedRateStats) positiveMentionRate() *float64 {
func (s *MonitoringService) loadDerivedParseMetrics(
ctx context.Context,
tenantID, brandID int64,
+ questionIDs []int64,
brandName string,
startDate, endDate time.Time,
aiPlatformID *string,
) (monitoringDerivedMetrics, error) {
metrics := newMonitoringDerivedMetrics()
+ if questionIDs != nil && len(questionIDs) == 0 {
+ return metrics, nil
+ }
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
rows, err := s.monitoringPool.Query(ctx, `
@@ -2221,8 +2263,9 @@ func (s *MonitoringService) loadDerivedParseMetrics(
AND r.collector_type = $3
AND r.status = 'succeeded'
AND r.business_date BETWEEN $4::date AND $5::date
- AND ($6::text[] IS NULL OR r.ai_platform_id = ANY($6))
- `, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableStringArray(platformQueryIDs))
+ AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
+ AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
+ `, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs))
if err != nil {
return metrics, response.ErrInternal(50041, "query_failed", "failed to load monitoring derived parse metrics")
}
@@ -2318,11 +2361,15 @@ func decodeMonitoringMatchedBrandTerms(raw []byte) []string {
func (s *MonitoringService) loadOverview(
ctx context.Context,
tenantID, brandID int64,
+ questionIDs []int64,
startDate, endDate time.Time,
collectionMode string,
aiPlatformID *string,
derived monitoringDerivedMetrics,
) (MonitoringOverview, time.Time, error) {
+ if questionIDs != nil {
+ return s.loadOverviewFromRuns(ctx, tenantID, brandID, questionIDs, startDate, endDate, collectionMode, aiPlatformID, derived)
+ }
if platformID := normalizedOptionalMonitoringPlatformID(aiPlatformID); platformID != "" {
return s.loadOverviewForPlatform(ctx, tenantID, brandID, startDate, endDate, collectionMode, platformID, derived)
}
@@ -2364,6 +2411,56 @@ func (s *MonitoringService) loadOverview(
return overview, latestDate, nil
}
+func (s *MonitoringService) loadOverviewFromRuns(
+ ctx context.Context,
+ tenantID, brandID int64,
+ questionIDs []int64,
+ startDate, endDate time.Time,
+ collectionMode string,
+ aiPlatformID *string,
+ derived monitoringDerivedMetrics,
+) (MonitoringOverview, time.Time, error) {
+ overview := MonitoringOverview{
+ CollectionMode: collectionMode,
+ ConfidenceLevel: "low",
+ }
+ latestDate := endDate
+ if len(questionIDs) == 0 {
+ return overview, latestDate, nil
+ }
+
+ platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
+ current, found, err := s.loadOverviewRunAggregateForDate(ctx, tenantID, brandID, questionIDs, endDate, platformQueryIDs)
+ if err != nil {
+ return overview, latestDate, err
+ }
+ if !found {
+ return overview, latestDate, nil
+ }
+
+ applyOverviewSnapshot(&overview, current, derived)
+ if current.ActualSampleCount <= 0 {
+ return overview, latestDate, nil
+ }
+
+ latestDate = current.Date
+ previous, foundPrevious, err := s.loadLatestOverviewRunAggregate(ctx, tenantID, brandID, questionIDs, startDate, current.Date.AddDate(0, 0, -1), platformQueryIDs)
+ if err != nil {
+ return overview, latestDate, err
+ }
+ if !foundPrevious {
+ return overview, latestDate, nil
+ }
+
+ applyDerivedRatesToOverviewSnapshot(&previous, derived)
+ overview.PrevSnapshotMentionRate = floatPointer(previous.MentionRate)
+ overview.PrevSnapshotTop1MentionRate = floatPointer(previous.Top1MentionRate)
+ overview.PrevSnapshotFirstRecommendRate = floatPointer(previous.FirstRecommendRate)
+ overview.PrevSnapshotPositiveMentionRate = floatPointer(previous.PositiveMentionRate)
+
+ return overview, latestDate, nil
+}
+
func (s *MonitoringService) loadOverviewForPlatform(
ctx context.Context,
tenantID, brandID int64,
@@ -2434,6 +2531,140 @@ type monitoringOverviewSnapshot struct {
CitationRate sql.NullFloat64
}
+func (s *MonitoringService) loadOverviewRunAggregateForDate(
+ ctx context.Context,
+ tenantID, brandID int64,
+ questionIDs []int64,
+ businessDate time.Time,
+ platformIDs []string,
+) (monitoringOverviewSnapshot, bool, error) {
+ return s.loadOverviewRunAggregate(ctx, tenantID, brandID, questionIDs, businessDate, businessDate, platformIDs, false)
+}
+
+func (s *MonitoringService) loadLatestOverviewRunAggregate(
+ ctx context.Context,
+ tenantID, brandID int64,
+ questionIDs []int64,
+ startDate, endDate time.Time,
+ platformIDs []string,
+) (monitoringOverviewSnapshot, bool, error) {
+ if endDate.Before(startDate) {
+ return monitoringOverviewSnapshot{}, false, nil
+ }
+ return s.loadOverviewRunAggregate(ctx, tenantID, brandID, questionIDs, startDate, endDate, platformIDs, true)
+}
+
+func (s *MonitoringService) loadOverviewRunAggregate(
+ ctx context.Context,
+ tenantID, brandID int64,
+ questionIDs []int64,
+ startDate, endDate time.Time,
+ platformIDs []string,
+ requireSamples bool,
+) (monitoringOverviewSnapshot, bool, error) {
+ var item monitoringOverviewSnapshot
+ if len(questionIDs) == 0 {
+ return item, false, nil
+ }
+
+ rows, err := s.monitoringPool.Query(ctx, `
+ SELECT
+ r.business_date,
+ COUNT(*)::bigint AS actual_sample_count,
+ COUNT(*) FILTER (WHERE p.brand_mentioned IS TRUE)::bigint AS mentioned_count,
+ COUNT(*) FILTER (WHERE p.brand_mention_position = 'top1')::bigint AS top1_mentioned_count,
+ COUNT(*) FILTER (WHERE p.first_recommended IS TRUE)::bigint AS first_recommended_count,
+ COUNT(*) FILTER (WHERE p.sentiment_label = 'positive')::bigint AS positive_mentioned_count,
+ MAX(r.completed_at) AS last_sampled_at
+ FROM question_monitor_runs r
+ LEFT JOIN question_monitor_parse_results p
+ ON p.tenant_id = r.tenant_id AND p.run_id = r.id
+ WHERE r.tenant_id = $1
+ AND r.brand_id = $2
+ AND r.collector_type = $3
+ AND r.status = 'succeeded'
+ AND r.business_date BETWEEN $4::date AND $5::date
+ AND r.question_id = ANY($6)
+ AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
+ GROUP BY r.business_date
+ ORDER BY r.business_date DESC
+ `, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), questionIDs, nullableStringArray(platformIDs))
+ if err != nil {
+ return item, false, response.ErrInternal(50041, "query_failed", "failed to load monitoring overview")
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var mentionedCount int64
+ var top1MentionedCount int64
+ var firstRecommendedCount int64
+ var positiveMentionedCount int64
+ if scanErr := rows.Scan(
+ &item.Date,
+ &item.ActualSampleCount,
+ &mentionedCount,
+ &top1MentionedCount,
+ &firstRecommendedCount,
+ &positiveMentionedCount,
+ &item.SnapshotUpdatedAt,
+ ); scanErr != nil {
+ return monitoringOverviewSnapshot{}, false, response.ErrInternal(50041, "scan_failed", "failed to parse monitoring overview")
+ }
+ if requireSamples && item.ActualSampleCount <= 0 {
+ continue
+ }
+
+ plannedSampleCount, planErr := s.loadOverviewRunPlannedCount(ctx, tenantID, brandID, questionIDs, item.Date, platformIDs)
+ if planErr != nil {
+ return monitoringOverviewSnapshot{}, false, planErr
+ }
+ item.DesiredSampleCount = plannedSampleCount
+ item.PlannedSampleCount = plannedSampleCount
+ item.CoverageRate = pointerFloat64ToNull(divideAsPointer(item.ActualSampleCount, plannedSampleCount))
+ item.ConfidenceLevel = deriveMonitoringConfidenceLevel(item.ActualSampleCount, plannedSampleCount, floatPointer(item.CoverageRate))
+ item.MentionRate = pointerFloat64ToNull(divideAsPointer(mentionedCount, item.ActualSampleCount))
+ item.Top1MentionRate = pointerFloat64ToNull(divideAsPointer(top1MentionedCount, item.ActualSampleCount))
+ item.FirstRecommendRate = pointerFloat64ToNull(divideAsPointer(firstRecommendedCount, item.ActualSampleCount))
+ item.PositiveMentionRate = pointerFloat64ToNull(divideAsPointer(positiveMentionedCount, item.ActualSampleCount))
+ return item, true, nil
+ }
+ if err := rows.Err(); err != nil {
+ return monitoringOverviewSnapshot{}, false, response.ErrInternal(50041, "scan_failed", "failed to iterate monitoring overview")
+ }
+
+ return monitoringOverviewSnapshot{}, false, nil
+}
+
+func (s *MonitoringService) loadOverviewRunPlannedCount(
+ ctx context.Context,
+ tenantID, brandID int64,
+ questionIDs []int64,
+ businessDate time.Time,
+ platformIDs []string,
+) (int64, error) {
+ var plannedSampleCount int64
+ if err := s.monitoringPool.QueryRow(ctx, `
+ SELECT COUNT(*)::bigint
+ FROM monitoring_collect_tasks
+ WHERE tenant_id = $1
+ AND brand_id = $2
+ AND collector_type = $3
+ AND business_date = $4::date
+ AND question_id = ANY($5)
+ AND ($6::text[] IS NULL OR ai_platform_id = ANY($6))
+ AND NOT (status = 'skipped' AND skip_reason = ANY($7::text[]))
+ `, tenantID, brandID, monitoringCollectorType, businessDate.Format("2006-01-02"), questionIDs, nullableStringArray(platformIDs), monitoringProjectionExcludedSkipReasons).Scan(&plannedSampleCount); err != nil {
+ return 0, response.ErrInternal(50041, "query_failed", "failed to load monitoring overview")
+ }
+ if plannedSampleCount > 0 {
+ return plannedSampleCount, nil
+ }
+ if platformIDs != nil {
+ return int64(len(questionIDs) * len(platformIDs)), nil
+ }
+ return int64(len(questionIDs) * len(defaultMonitoringPlatforms)), nil
+}
+
func (s *MonitoringService) loadOverviewSnapshotForDate(ctx context.Context, tenantID, brandID int64, collectionMode string, businessDate time.Time) (monitoringOverviewSnapshot, bool, error) {
var item monitoringOverviewSnapshot
err := s.monitoringPool.QueryRow(ctx, `
@@ -2765,7 +2996,11 @@ func (s *MonitoringService) monitoringClientBelongsToWorkspace(ctx context.Conte
return exists, nil
}
-func (s *MonitoringService) loadPlatformBreakdown(ctx context.Context, tenantID, brandID int64, businessDate time.Time, collectionMode string, platforms []monitoringPlatformMetadata, accessStates map[string]monitoringAccessState, derived monitoringDerivedMetrics) ([]MonitoringPlatformDaily, error) {
+func (s *MonitoringService) loadPlatformBreakdown(ctx context.Context, tenantID, brandID int64, questionIDs []int64, businessDate time.Time, collectionMode string, platforms []monitoringPlatformMetadata, accessStates map[string]monitoringAccessState, derived monitoringDerivedMetrics) ([]MonitoringPlatformDaily, error) {
+ if questionIDs != nil {
+ return s.loadPlatformBreakdownFromRuns(ctx, tenantID, brandID, questionIDs, businessDate, platforms, accessStates, derived)
+ }
+
rows, err := s.monitoringPool.Query(ctx, `
SELECT
ai_platform_id,
@@ -2854,6 +3089,102 @@ func (s *MonitoringService) loadPlatformBreakdown(ctx context.Context, tenantID,
return breakdown, nil
}
+func (s *MonitoringService) loadPlatformBreakdownFromRuns(ctx context.Context, tenantID, brandID int64, questionIDs []int64, businessDate time.Time, platforms []monitoringPlatformMetadata, accessStates map[string]monitoringAccessState, derived monitoringDerivedMetrics) ([]MonitoringPlatformDaily, error) {
+ if len(questionIDs) == 0 {
+ return emptyPlatformBreakdown(platforms, accessStates), nil
+ }
+
+ dateKey := businessDate.Format("2006-01-02")
+ rows, err := s.monitoringPool.Query(ctx, `
+ SELECT
+ r.ai_platform_id,
+ COUNT(*)::bigint AS actual_sample_count,
+ COUNT(*) FILTER (WHERE p.brand_mentioned IS TRUE)::bigint AS mentioned_count,
+ COUNT(*) FILTER (WHERE p.brand_mention_position = 'top1')::bigint AS top1_mentioned_count,
+ MAX(r.completed_at) AS last_sampled_at
+ FROM question_monitor_runs r
+ LEFT JOIN question_monitor_parse_results p
+ ON p.tenant_id = r.tenant_id AND p.run_id = r.id
+ WHERE r.tenant_id = $1
+ AND r.brand_id = $2
+ AND r.collector_type = $3
+ AND r.business_date = $4::date
+ AND r.status = 'succeeded'
+ AND r.question_id = ANY($5)
+ GROUP BY r.ai_platform_id
+ `, tenantID, brandID, monitoringCollectorType, dateKey, questionIDs)
+ if err != nil {
+ return nil, response.ErrInternal(50041, "query_failed", "failed to load platform breakdown")
+ }
+ defer rows.Close()
+
+ type row struct {
+ MentionRate sql.NullFloat64
+ Top1MentionRate sql.NullFloat64
+ ActualSampleCount int64
+ LastSampledAt sql.NullTime
+ }
+
+ items := make(map[string]row)
+ for rows.Next() {
+ var platformID string
+ var mentionedCount int64
+ var top1MentionedCount int64
+ var item row
+ if scanErr := rows.Scan(
+ &platformID,
+ &item.ActualSampleCount,
+ &mentionedCount,
+ &top1MentionedCount,
+ &item.LastSampledAt,
+ ); scanErr != nil {
+ return nil, response.ErrInternal(50041, "scan_failed", "failed to parse platform breakdown")
+ }
+ platformID = normalizeMonitoringPlatformID(platformID)
+ if platformID == "" {
+ continue
+ }
+ item.MentionRate = pointerFloat64ToNull(divideAsPointer(mentionedCount, item.ActualSampleCount))
+ item.Top1MentionRate = pointerFloat64ToNull(divideAsPointer(top1MentionedCount, item.ActualSampleCount))
+ items[platformID] = item
+ }
+ if err := rows.Err(); err != nil {
+ return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate platform breakdown")
+ }
+
+ breakdown := make([]MonitoringPlatformDaily, 0, len(platforms))
+ for _, platform := range platforms {
+ item := items[platform.ID]
+ if platformStats, ok := derived.ByDatePlatform[dateKey][platform.ID]; ok && platformStats.hasSamples() {
+ item.MentionRate = pointerFloat64ToNull(platformStats.mentionRate())
+ item.Top1MentionRate = pointerFloat64ToNull(platformStats.top1MentionRate())
+ }
+ breakdown = append(breakdown, MonitoringPlatformDaily{
+ AIPlatformID: platform.ID,
+ PlatformName: platform.Name,
+ MentionRate: floatPointer(item.MentionRate),
+ Top1MentionRate: floatPointer(item.Top1MentionRate),
+ PlatformSampleStatus: deriveMonitoringPlatformSampleStatus(item.ActualSampleCount, item.ActualSampleCount, accessStates[platform.ID].AccessStatus),
+ ActualSampleCount: item.ActualSampleCount,
+ LastSampledAt: formatNullTime(item.LastSampledAt),
+ })
+ }
+
+ return breakdown, nil
+}
+
+func emptyPlatformBreakdown(platforms []monitoringPlatformMetadata, accessStates map[string]monitoringAccessState) []MonitoringPlatformDaily {
+ breakdown := make([]MonitoringPlatformDaily, 0, len(platforms))
+ for _, platform := range platforms {
+ breakdown = append(breakdown, MonitoringPlatformDaily{
+ AIPlatformID: platform.ID,
+ PlatformName: platform.Name,
+ PlatformSampleStatus: derivePlatformSampleStatus("", accessStates[platform.ID]),
+ })
+ }
+ return breakdown
+}
+
func (s *MonitoringService) loadHotQuestions(
ctx context.Context,
tenantID, brandID int64,
diff --git a/server/internal/tenant/app/question_combination_fill_prompt.go b/server/internal/tenant/app/question_combination_fill_prompt.go
new file mode 100644
index 0000000..72dad8f
--- /dev/null
+++ b/server/internal/tenant/app/question_combination_fill_prompt.go
@@ -0,0 +1,75 @@
+package app
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+var questionCombinationFillSchema = []byte(`{
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "region": {
+ "type": "array",
+ "maxItems": 4,
+ "items": { "type": "string", "minLength": 1, "maxLength": 16 }
+ },
+ "prefix": {
+ "type": "array",
+ "maxItems": 4,
+ "items": { "type": "string", "minLength": 1, "maxLength": 16 }
+ },
+ "core": {
+ "type": "array",
+ "maxItems": 4,
+ "items": { "type": "string", "minLength": 1, "maxLength": 18 }
+ },
+ "industry": {
+ "type": "array",
+ "maxItems": 4,
+ "items": { "type": "string", "minLength": 1, "maxLength": 18 }
+ },
+ "suffix": {
+ "type": "array",
+ "maxItems": 4,
+ "items": { "type": "string", "minLength": 1, "maxLength": 16 }
+ }
+ },
+ "required": ["region", "prefix", "core", "industry", "suffix"]
+}`)
+
+func buildQuestionCombinationFillPrompt(brandCtx *questionBrandContext, regionTerms []string, req QuestionCombinationFillRequest) string {
+ competitors := "无"
+ if len(brandCtx.CompetitorNames) > 0 {
+ encoded, _ := json.Marshal(brandCtx.CompetitorNames)
+ competitors = string(encoded)
+ }
+ hints, _ := json.Marshal(req)
+ return fmt.Sprintf(`你是中国 GEO 搜索问题拓展策略师。请为“拓词工具”的 5 个词列生成高频、核心、可组合的问题词组。
+
+【品牌信息】
+- 品牌: %s
+- 官网: %s
+- 描述: %s
+- 竞品: %s
+
+【地域优先词】
+%s
+
+【当前输入,仅作为语义参考,可改写】
+%s
+
+【输出要求】
+1. region 使用地域优先词,不要编造不存在的城市;没有有效地域时可输出全国、本地、华东等泛地域词。
+2. prefix/core/industry/suffix 各输出 3 到 4 个词,必须短、热、可直接拼成搜索问题。
+3. core 要贴近品牌最核心的搜索需求;industry 要贴近行业/品类;suffix 偏“哪家好、怎么选、推荐、多少钱”等高转化问法。
+4. 不要输出完整问题,不要带标点,不要解释,严格按 JSON Schema 输出。`,
+ strings.TrimSpace(brandCtx.BrandName),
+ strings.TrimSpace(nilToString(brandCtx.Website)),
+ strings.TrimSpace(nilToString(brandCtx.Description)),
+ competitors,
+ strings.Join(regionTerms, "、"),
+ string(hints),
+ )
+}
diff --git a/server/internal/tenant/app/question_combination_fill_test.go b/server/internal/tenant/app/question_combination_fill_test.go
new file mode 100644
index 0000000..afb7001
--- /dev/null
+++ b/server/internal/tenant/app/question_combination_fill_test.go
@@ -0,0 +1,32 @@
+package app
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestQuestionCombinationRegionTermsFromIPRegion(t *testing.T) {
+ got := questionCombinationRegionTermsFromIPRegion("中国|0|安徽省|合肥市|电信")
+ want := []string{"合肥", "华东"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("region terms = %#v, want %#v", got, want)
+ }
+}
+
+func TestBuildQuestionCombinationFillFallback(t *testing.T) {
+ description := "GEO 与 AI 搜索优化平台"
+ got := buildQuestionCombinationFillFallback(&questionBrandContext{
+ BrandName: "GeoRankly",
+ Description: &description,
+ }, []string{"上海", "华东"}, QuestionCombinationFillRequest{})
+
+ if !reflect.DeepEqual(got.Region, []string{"上海", "华东"}) {
+ t.Fatalf("region = %#v", got.Region)
+ }
+ if len(got.Core) < 3 || got.Core[0] != "GEO" {
+ t.Fatalf("core fallback = %#v", got.Core)
+ }
+ if len(got.Prefix) != 4 || len(got.Industry) != 4 || len(got.Suffix) != 4 {
+ t.Fatalf("expected four hot terms for non-region columns, got %#v", got)
+ }
+}
diff --git a/server/internal/tenant/app/question_expansion_service.go b/server/internal/tenant/app/question_expansion_service.go
index 26c483b..8f60683 100644
--- a/server/internal/tenant/app/question_expansion_service.go
+++ b/server/internal/tenant/app/question_expansion_service.go
@@ -17,16 +17,20 @@ import (
"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/ipregion"
"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
+ questionCombinationMaxItems = 500
+ questionCombinationFillMax = 4
+ questionAIDistillMaxItems = 20
+ questionCombinationFillTTL = 10 * time.Minute
+ questionCombinationFillTimeout = 15 * time.Second
+ questionDistillCacheTTL = 5 * time.Minute
+ questionDistillTimeout = 30 * time.Second
)
type QuestionExpansionService struct {
@@ -34,6 +38,7 @@ type QuestionExpansionService struct {
llm llm.Client
brand *BrandService
cache sharedcache.Cache
+ ipGeo *ipregion.Resolver
}
func NewQuestionExpansionService(pool *pgxpool.Pool, llmClient llm.Client, brand *BrandService) *QuestionExpansionService {
@@ -49,6 +54,11 @@ func (s *QuestionExpansionService) WithCache(c sharedcache.Cache) *QuestionExpan
return s
}
+func (s *QuestionExpansionService) WithIPRegionResolver(resolver *ipregion.Resolver) *QuestionExpansionService {
+ s.ipGeo = resolver
+ return s
+}
+
type QuestionCombinationRequest struct {
Region []string `json:"region"`
Prefix []string `json:"prefix"`
@@ -62,6 +72,24 @@ type QuestionDistillRequest struct {
SeedTopic string `json:"seed_topic" binding:"required,min=2"`
}
+type QuestionCombinationFillRequest struct {
+ Region []string `json:"region"`
+ Prefix []string `json:"prefix"`
+ Core []string `json:"core"`
+ Industry []string `json:"industry"`
+ Suffix []string `json:"suffix"`
+}
+
+type QuestionCombinationFillResult struct {
+ Region []string `json:"region"`
+ Prefix []string `json:"prefix"`
+ Core []string `json:"core"`
+ Industry []string `json:"industry"`
+ Suffix []string `json:"suffix"`
+ AIPointsCharged int `json:"ai_points_charged,omitempty"`
+ CacheHit bool `json:"cache_hit,omitempty"`
+}
+
type QuestionCandidate struct {
Text string `json:"text"`
Layer string `json:"layer"`
@@ -103,6 +131,8 @@ type questionBrandContext struct {
TenantID int64
BrandID int64
BrandName string
+ Website *string
+ Description *string
PlanCode string
CompetitorNames []string
}
@@ -115,6 +145,14 @@ type aiDistillPayload struct {
} `json:"candidates"`
}
+type aiCombinationFillPayload struct {
+ Region []string `json:"region"`
+ Prefix []string `json:"prefix"`
+ Core []string `json:"core"`
+ Industry []string `json:"industry"`
+ Suffix []string `json:"suffix"`
+}
+
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)
@@ -250,7 +288,7 @@ func (s *QuestionExpansionService) GenerateByAIDistill(ctx context.Context, bran
}, 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) {
+ if errors.Is(err, context.DeadlineExceeded) || 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())
@@ -296,6 +334,99 @@ func (s *QuestionExpansionService) GenerateByAIDistill(ctx context.Context, bran
}, nil
}
+func (s *QuestionExpansionService) FillQuestionCombination(ctx context.Context, brandID int64, clientIP string, req QuestionCombinationFillRequest) (*QuestionCombinationFillResult, error) {
+ actor := auth.MustActor(ctx)
+ brandCtx, err := s.loadQuestionBrandContext(ctx, actor.TenantID, brandID)
+ if err != nil {
+ return nil, err
+ }
+
+ regionTerms := questionCombinationRegionTermsFromIPRegion("")
+ if s.ipGeo != nil {
+ regionTerms = questionCombinationRegionTermsFromIPRegion(s.ipGeo.Lookup(clientIP))
+ }
+ fallback := buildQuestionCombinationFillFallback(brandCtx, regionTerms, req)
+
+ cacheKey := questionCombinationFillCacheKey(actor.TenantID, brandID, brandCtx, regionTerms, req)
+ if s.cache != nil {
+ if raw, cacheErr := s.cache.Get(ctx, cacheKey); cacheErr == nil && len(raw) > 0 {
+ var cached QuestionCombinationFillResult
+ if json.Unmarshal(raw, &cached) == nil {
+ cached.CacheHit = true
+ cached.AIPointsCharged = 0
+ return &cached, nil
+ }
+ }
+ }
+
+ if s.llm == nil || s.llm.Validate() != nil {
+ return &fallback, nil
+ }
+
+ resourceType := "question_combination_fill"
+ resourceUID := cacheKey
+ meteredText, _ := json.Marshal(map[string]any{
+ "brand": brandCtx.BrandName,
+ "description": nilToString(brandCtx.Description),
+ "region": fallback.Region,
+ "request": req,
+ })
+ reservation, err := ReserveAIPoints(ctx, s.pool, s.cache, AIPointReserveInput{
+ TenantID: actor.TenantID,
+ OperatorID: actor.UserID,
+ UsageType: AIUsageTypeQuestionCombinationFill,
+ ResourceType: &resourceType,
+ ResourceUID: &resourceUID,
+ MeteredText: string(meteredText),
+ FixedPoints: 1,
+ Metadata: map[string]any{
+ "brand_id": brandID,
+ "prompt_version": "question_combination_fill_v1",
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ result, err := s.llm.Generate(ctx, llm.GenerateRequest{
+ Prompt: buildQuestionCombinationFillPrompt(brandCtx, fallback.Region, req),
+ Timeout: questionCombinationFillTimeout,
+ MaxOutputTokens: 900,
+ ResponseFormat: &llm.ResponseFormat{
+ Type: llm.ResponseFormatTypeJSONSchema,
+ Name: "question_combination_fill",
+ Description: "Short word columns for question expansion combination tool.",
+ SchemaJSON: questionCombinationFillSchema,
+ Strict: true,
+ },
+ }, nil)
+ if err != nil {
+ _ = RefundAIPoints(context.Background(), s.pool, s.cache, actor.TenantID, actor.UserID, *reservation, err.Error())
+ return &fallback, nil
+ }
+
+ var payload aiCombinationFillPayload
+ 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 &fallback, nil
+ }
+
+ filled := sanitizeQuestionCombinationFillResult(payload, fallback)
+ 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")
+ }
+ filled.AIPointsCharged = reservation.Points
+ if s.cache != nil {
+ cached := filled
+ cached.AIPointsCharged = 0
+ cached.CacheHit = false
+ if raw, err := json.Marshal(cached); err == nil {
+ _ = s.cache.Set(context.Background(), cacheKey, raw, questionCombinationFillTTL)
+ }
+ }
+ return &filled, 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)
@@ -485,11 +616,13 @@ func (s *QuestionExpansionService) MaterializeQuestions(ctx context.Context, bra
func (s *QuestionExpansionService) loadQuestionBrandContext(ctx context.Context, tenantID, brandID int64) (*questionBrandContext, error) {
var brandName string
+ var website *string
+ var description *string
err := s.pool.QueryRow(ctx, `
- SELECT name
+ SELECT name, website, description
FROM brands
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
- `, brandID, tenantID).Scan(&brandName)
+ `, brandID, tenantID).Scan(&brandName, &website, &description)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrNotFound(40420, "brand_not_found", "brand not found")
@@ -525,6 +658,8 @@ func (s *QuestionExpansionService) loadQuestionBrandContext(ctx context.Context,
TenantID: tenantID,
BrandID: brandID,
BrandName: brandName,
+ Website: website,
+ Description: description,
PlanCode: plan.PlanCode,
CompetitorNames: competitors,
}, nil
@@ -642,6 +777,317 @@ func optionalQuestionParts(values []string) []string {
return parts
}
+func buildQuestionCombinationFillFallback(brandCtx *questionBrandContext, regionTerms []string, req QuestionCombinationFillRequest) QuestionCombinationFillResult {
+ brandName := strings.TrimSpace(brandCtx.BrandName)
+ description := strings.TrimSpace(nilToString(brandCtx.Description))
+ category := inferQuestionCombinationCategory(brandName, description)
+
+ region := compactQuestionCombinationTerms(regionTerms, questionCombinationFillMax)
+ if len(region) == 0 {
+ region = compactQuestionCombinationTerms(req.Region, questionCombinationFillMax)
+ }
+ if len(region) == 0 {
+ region = []string{"全国", "本地", "华东"}
+ }
+
+ prefix := compactQuestionCombinationTerms(req.Prefix, questionCombinationFillMax)
+ if len(prefix) == 0 {
+ prefix = []string{"好用的", "专业的", "靠谱的", "热门的"}
+ }
+
+ core := compactQuestionCombinationTerms(req.Core, questionCombinationFillMax)
+ if len(core) == 0 {
+ core = questionCombinationCoreFallback(brandName, description)
+ }
+
+ industry := compactQuestionCombinationTerms(req.Industry, questionCombinationFillMax)
+ if len(industry) == 0 {
+ industry = []string{category, "平台", "工具", "服务商"}
+ }
+
+ suffix := compactQuestionCombinationTerms(req.Suffix, questionCombinationFillMax)
+ if len(suffix) == 0 {
+ suffix = []string{"哪家好", "怎么选", "推荐", "多少钱"}
+ }
+
+ return QuestionCombinationFillResult{
+ Region: compactQuestionCombinationTerms(region, questionCombinationFillMax),
+ Prefix: compactQuestionCombinationTerms(prefix, questionCombinationFillMax),
+ Core: compactQuestionCombinationTerms(core, questionCombinationFillMax),
+ Industry: compactQuestionCombinationTerms(industry, questionCombinationFillMax),
+ Suffix: compactQuestionCombinationTerms(suffix, questionCombinationFillMax),
+ }
+}
+
+func sanitizeQuestionCombinationFillResult(payload aiCombinationFillPayload, fallback QuestionCombinationFillResult) QuestionCombinationFillResult {
+ return QuestionCombinationFillResult{
+ Region: fillQuestionCombinationTerms(payload.Region, fallback.Region),
+ Prefix: fillQuestionCombinationTerms(payload.Prefix, fallback.Prefix),
+ Core: fillQuestionCombinationTerms(payload.Core, fallback.Core),
+ Industry: fillQuestionCombinationTerms(payload.Industry, fallback.Industry),
+ Suffix: fillQuestionCombinationTerms(payload.Suffix, fallback.Suffix),
+ }
+}
+
+func fillQuestionCombinationTerms(values, fallback []string) []string {
+ result := compactQuestionCombinationTerms(values, questionCombinationFillMax)
+ if len(result) >= 3 {
+ return result
+ }
+ for _, item := range fallback {
+ result = append(result, item)
+ result = compactQuestionCombinationTerms(result, questionCombinationFillMax)
+ if len(result) >= 3 {
+ break
+ }
+ }
+ return result
+}
+
+func compactQuestionCombinationTerms(values []string, limit int) []string {
+ if limit <= 0 {
+ limit = questionCombinationFillMax
+ }
+ result := make([]string, 0, minInt(len(values), limit))
+ seen := map[string]struct{}{}
+ for _, value := range values {
+ term := normalizeQuestionCombinationTerm(value)
+ if term == "" {
+ continue
+ }
+ key := strings.ToLower(term)
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ result = append(result, term)
+ if len(result) >= limit {
+ break
+ }
+ }
+ return result
+}
+
+func normalizeQuestionCombinationTerm(value string) string {
+ term := strings.TrimSpace(value)
+ term = strings.Trim(term, ",,。!?!?;;、 \t\r\n")
+ term = strings.ReplaceAll(term, " ", "")
+ term = strings.ReplaceAll(term, "\u3000", "")
+ if term == "" || term == "0" || strings.EqualFold(term, "null") {
+ return ""
+ }
+ if utf8.RuneCountInString(term) > 18 {
+ runes := []rune(term)
+ term = string(runes[:18])
+ }
+ return term
+}
+
+func questionCombinationRegionTermsFromIPRegion(region string) []string {
+ parts := strings.Split(strings.TrimSpace(region), "|")
+ if len(parts) < 4 {
+ return nil
+ }
+ country := normalizeChineseLocationTerm(parts[0])
+ province := normalizeChineseLocationTerm(parts[2])
+ city := normalizeChineseLocationTerm(parts[3])
+ if country != "" && country != "中国" {
+ return []string{country}
+ }
+ capital := provinceCapitalTerm(province)
+ area := chineseMacroRegion(province)
+ return compactQuestionCombinationTerms([]string{city, capital, area}, 3)
+}
+
+func normalizeChineseLocationTerm(value string) string {
+ term := normalizeQuestionCombinationTerm(value)
+ if term == "" || term == "本机" || term == "内网IP" {
+ return ""
+ }
+ replacer := strings.NewReplacer(
+ "特别行政区", "",
+ "维吾尔自治区", "",
+ "壮族自治区", "",
+ "回族自治区", "",
+ "自治区", "",
+ "省", "",
+ "市", "",
+ "地区", "",
+ "盟", "",
+ )
+ return replacer.Replace(term)
+}
+
+func provinceCapitalTerm(province string) string {
+ switch province {
+ case "北京":
+ return "北京"
+ case "上海":
+ return "上海"
+ case "天津":
+ return "天津"
+ case "重庆":
+ return "重庆"
+ case "河北":
+ return "石家庄"
+ case "山西":
+ return "太原"
+ case "内蒙古":
+ return "呼和浩特"
+ case "辽宁":
+ return "沈阳"
+ case "吉林":
+ return "长春"
+ case "黑龙江":
+ return "哈尔滨"
+ case "江苏":
+ return "南京"
+ case "浙江":
+ return "杭州"
+ case "安徽":
+ return "合肥"
+ case "福建":
+ return "福州"
+ case "江西":
+ return "南昌"
+ case "山东":
+ return "济南"
+ case "河南":
+ return "郑州"
+ case "湖北":
+ return "武汉"
+ case "湖南":
+ return "长沙"
+ case "广东":
+ return "广州"
+ case "广西":
+ return "南宁"
+ case "海南":
+ return "海口"
+ case "四川":
+ return "成都"
+ case "贵州":
+ return "贵阳"
+ case "云南":
+ return "昆明"
+ case "西藏":
+ return "拉萨"
+ case "陕西":
+ return "西安"
+ case "甘肃":
+ return "兰州"
+ case "青海":
+ return "西宁"
+ case "宁夏":
+ return "银川"
+ case "新疆":
+ return "乌鲁木齐"
+ case "香港":
+ return "香港"
+ case "澳门":
+ return "澳门"
+ case "台湾":
+ return "台北"
+ default:
+ return ""
+ }
+}
+
+func chineseMacroRegion(province string) string {
+ switch province {
+ case "上海", "江苏", "浙江", "安徽", "福建", "江西", "山东", "台湾":
+ return "华东"
+ case "北京", "天津", "河北", "山西", "内蒙古":
+ return "华北"
+ case "河南", "湖北", "湖南":
+ return "华中"
+ case "广东", "广西", "海南", "香港", "澳门":
+ return "华南"
+ case "重庆", "四川", "贵州", "云南", "西藏":
+ return "西南"
+ case "陕西", "甘肃", "青海", "宁夏", "新疆":
+ return "西北"
+ case "辽宁", "吉林", "黑龙江":
+ return "东北"
+ default:
+ return ""
+ }
+}
+
+func questionCombinationCoreFallback(brandName, description string) []string {
+ text := strings.ToLower(strings.TrimSpace(brandName + " " + description))
+ if strings.Contains(text, "geo") || strings.Contains(text, "ai") || strings.Contains(text, "搜索") || strings.Contains(text, "排名") {
+ return []string{"GEO", "AI搜索优化", "AI搜索排名", "品牌可见度"}
+ }
+ if strings.Contains(text, "教育") || strings.Contains(text, "培训") || strings.Contains(text, "课程") {
+ return []string{"课程", "培训", "学习", "机构"}
+ }
+ if strings.Contains(text, "医疗") || strings.Contains(text, "健康") || strings.Contains(text, "诊所") {
+ return []string{"医疗服务", "健康管理", "医生", "诊疗"}
+ }
+ if strings.Contains(text, "家居") || strings.Contains(text, "装修") || strings.Contains(text, "建材") {
+ return []string{"装修", "家居", "建材", "设计"}
+ }
+ return []string{strings.TrimSpace(brandName), "解决方案", "服务", "平台"}
+}
+
+func inferQuestionCombinationCategory(brandName, description string) string {
+ text := strings.ToLower(strings.TrimSpace(brandName + " " + description))
+ switch {
+ case strings.Contains(text, "geo") || strings.Contains(text, "seo") || strings.Contains(text, "搜索"):
+ return "营销"
+ case strings.Contains(text, "教育") || strings.Contains(text, "培训") || strings.Contains(text, "课程"):
+ return "教育"
+ case strings.Contains(text, "医疗") || strings.Contains(text, "健康"):
+ return "健康"
+ case strings.Contains(text, "家居") || strings.Contains(text, "装修") || strings.Contains(text, "建材"):
+ return "家居"
+ case strings.Contains(text, "餐饮") || strings.Contains(text, "美食"):
+ return "餐饮"
+ case strings.Contains(text, "旅游") || strings.Contains(text, "酒店"):
+ return "旅游"
+ default:
+ return "服务"
+ }
+}
+
+func questionCombinationFillCacheKey(tenantID, brandID int64, brandCtx *questionBrandContext, regionTerms []string, req QuestionCombinationFillRequest) string {
+ raw, _ := json.Marshal(struct {
+ TenantID int64 `json:"tenant_id"`
+ BrandID int64 `json:"brand_id"`
+ SchemaVersion string `json:"schema_version"`
+ BrandName string `json:"brand_name"`
+ Description string `json:"description"`
+ Competitors []string `json:"competitors"`
+ Region []string `json:"region"`
+ Request QuestionCombinationFillRequest `json:"request"`
+ }{
+ TenantID: tenantID,
+ BrandID: brandID,
+ SchemaVersion: "question_combination_fill_v1",
+ BrandName: strings.TrimSpace(brandCtx.BrandName),
+ Description: strings.TrimSpace(nilToString(brandCtx.Description)),
+ Competitors: brandCtx.CompetitorNames,
+ Region: regionTerms,
+ Request: QuestionCombinationFillRequest{
+ Region: compactQuestionCombinationTerms(req.Region, questionCombinationFillMax),
+ Prefix: compactQuestionCombinationTerms(req.Prefix, questionCombinationFillMax),
+ Core: compactQuestionCombinationTerms(req.Core, questionCombinationFillMax),
+ Industry: compactQuestionCombinationTerms(req.Industry, questionCombinationFillMax),
+ Suffix: compactQuestionCombinationTerms(req.Suffix, questionCombinationFillMax),
+ },
+ })
+ sum := sha1.Sum(raw)
+ return "question_combination_fill:" + hex.EncodeToString(sum[:])
+}
+
+func nilToString(value *string) string {
+ if value == nil {
+ return ""
+ }
+ return *value
+}
+
func nonEmptyParts(values ...string) []string {
result := make([]string, 0, len(values))
for _, value := range values {
diff --git a/server/internal/tenant/app/template_assist.go b/server/internal/tenant/app/template_assist.go
index c8ff59d..ab4fe4c 100644
--- a/server/internal/tenant/app/template_assist.go
+++ b/server/internal/tenant/app/template_assist.go
@@ -30,13 +30,15 @@ type AssistCompetitor struct {
}
type AnalyzeTaskRequest struct {
- Locale string `json:"locale"`
- BrandID *int64 `json:"brand_id,omitempty"`
- BrandName string `json:"brand_name"`
- Website string `json:"website,omitempty"`
- InputParams map[string]interface{} `json:"input_params,omitempty"`
- ExistingKeywords []string `json:"existing_keywords,omitempty"`
- ExistingCompetitors []AssistCompetitor `json:"existing_competitors,omitempty"`
+ Locale string `json:"locale"`
+ BrandID *int64 `json:"brand_id,omitempty"`
+ BrandName string `json:"brand_name"`
+ Website string `json:"website,omitempty"`
+ BrandQuestion string `json:"brand_question,omitempty"`
+ SupplementalQuestions []string `json:"supplemental_questions,omitempty"`
+ InputParams map[string]interface{} `json:"input_params,omitempty"`
+ ExistingKeywords []string `json:"existing_keywords,omitempty"`
+ ExistingCompetitors []AssistCompetitor `json:"existing_competitors,omitempty"`
}
type AnalyzeTaskResult struct {
@@ -46,28 +48,32 @@ type AnalyzeTaskResult struct {
}
type TitleTaskRequest struct {
- Locale string `json:"locale"`
- BrandID *int64 `json:"brand_id,omitempty"`
- BrandName string `json:"brand_name"`
- Website string `json:"website,omitempty"`
- BrandSummary string `json:"brand_summary,omitempty"`
- InputParams map[string]interface{} `json:"input_params,omitempty"`
- Keywords []string `json:"keywords,omitempty"`
- Competitors []AssistCompetitor `json:"competitors,omitempty"`
+ Locale string `json:"locale"`
+ BrandID *int64 `json:"brand_id,omitempty"`
+ BrandName string `json:"brand_name"`
+ Website string `json:"website,omitempty"`
+ BrandSummary string `json:"brand_summary,omitempty"`
+ BrandQuestion string `json:"brand_question,omitempty"`
+ SupplementalQuestions []string `json:"supplemental_questions,omitempty"`
+ InputParams map[string]interface{} `json:"input_params,omitempty"`
+ Keywords []string `json:"keywords,omitempty"`
+ Competitors []AssistCompetitor `json:"competitors,omitempty"`
}
type OutlineTaskRequest struct {
- Locale string `json:"locale"`
- BrandID *int64 `json:"brand_id,omitempty"`
- BrandName string `json:"brand_name"`
- Website string `json:"website,omitempty"`
- BrandSummary string `json:"brand_summary,omitempty"`
- Title string `json:"title"`
- InputParams map[string]interface{} `json:"input_params,omitempty"`
- Keywords []string `json:"keywords,omitempty"`
- Competitors []AssistCompetitor `json:"competitors,omitempty"`
- OutlineSections []string `json:"outline_sections,omitempty"`
- KeyPoints string `json:"key_points,omitempty"`
+ Locale string `json:"locale"`
+ BrandID *int64 `json:"brand_id,omitempty"`
+ BrandName string `json:"brand_name"`
+ Website string `json:"website,omitempty"`
+ BrandSummary string `json:"brand_summary,omitempty"`
+ Title string `json:"title"`
+ BrandQuestion string `json:"brand_question,omitempty"`
+ SupplementalQuestions []string `json:"supplemental_questions,omitempty"`
+ InputParams map[string]interface{} `json:"input_params,omitempty"`
+ Keywords []string `json:"keywords,omitempty"`
+ Competitors []AssistCompetitor `json:"competitors,omitempty"`
+ OutlineSections []string `json:"outline_sections,omitempty"`
+ KeyPoints string `json:"key_points,omitempty"`
}
type OutlineNode struct {
@@ -632,6 +638,8 @@ func normalizeAnalyzeTaskRequest(req AnalyzeTaskRequest) AnalyzeTaskRequest {
}
req.BrandName = strings.TrimSpace(req.BrandName)
req.Website = strings.TrimSpace(req.Website)
+ req.BrandQuestion = strings.TrimSpace(req.BrandQuestion)
+ req.SupplementalQuestions = []string{}
req.ExistingKeywords = normalizeStringList(req.ExistingKeywords, 8)
req.ExistingCompetitors = normalizeAssistCompetitors(req.ExistingCompetitors, 8)
req.InputParams = normalizeAssistInputParams(req.InputParams)
@@ -646,7 +654,15 @@ func normalizeTitleTaskRequest(req TitleTaskRequest) TitleTaskRequest {
req.BrandName = strings.TrimSpace(req.BrandName)
req.Website = strings.TrimSpace(req.Website)
req.BrandSummary = strings.TrimSpace(req.BrandSummary)
+ req.BrandQuestion = strings.TrimSpace(req.BrandQuestion)
+ req.SupplementalQuestions = normalizeStringList(req.SupplementalQuestions, 3)
req.Keywords = normalizeStringList(req.Keywords, 12)
+ if req.BrandQuestion != "" {
+ req.SupplementalQuestions = []string{}
+ req.Keywords = []string{req.BrandQuestion}
+ } else if len(req.Keywords) == 0 {
+ req.Keywords = buildQuestionKeywordContext(req.BrandQuestion, req.SupplementalQuestions, 12)
+ }
req.Competitors = normalizeAssistCompetitors(req.Competitors, 8)
req.InputParams = normalizeAssistInputParams(req.InputParams)
return req
@@ -662,13 +678,22 @@ func normalizeOutlineTaskRequest(req OutlineTaskRequest) OutlineTaskRequest {
req.BrandSummary = strings.TrimSpace(req.BrandSummary)
req.Title = strings.TrimSpace(req.Title)
req.KeyPoints = strings.TrimSpace(req.KeyPoints)
+ req.BrandQuestion = strings.TrimSpace(req.BrandQuestion)
+ req.SupplementalQuestions = normalizeStringList(req.SupplementalQuestions, 3)
req.Keywords = normalizeStringList(req.Keywords, 12)
+ if len(req.Keywords) == 0 {
+ req.Keywords = buildQuestionKeywordContext(req.BrandQuestion, req.SupplementalQuestions, 12)
+ }
req.Competitors = normalizeAssistCompetitors(req.Competitors, 8)
req.OutlineSections = normalizeStringList(req.OutlineSections, 24)
req.InputParams = normalizeAssistInputParams(req.InputParams)
return req
}
+func buildQuestionKeywordContext(primaryQuestion string, supplementalQuestions []string, max int) []string {
+ return normalizeStringList(append([]string{primaryQuestion}, supplementalQuestions...), max)
+}
+
func normalizeAssistInputParams(params map[string]interface{}) map[string]interface{} {
if params == nil {
return map[string]interface{}{}
@@ -682,7 +707,10 @@ func normalizeAssistInputParams(params map[string]interface{}) map[string]interf
}
func hasAnalyzeContext(req AnalyzeTaskRequest) bool {
- if req.BrandName != "" || req.Website != "" {
+ if req.BrandName != "" || req.Website != "" || req.BrandQuestion != "" {
+ return true
+ }
+ if len(req.SupplementalQuestions) > 0 {
return true
}
for _, value := range req.InputParams {
@@ -707,7 +735,10 @@ func hasAnalyzeContext(req AnalyzeTaskRequest) bool {
}
func hasTitleContext(req TitleTaskRequest) bool {
- if req.BrandName != "" || req.Website != "" || req.BrandSummary != "" {
+ if req.BrandName != "" || req.Website != "" || req.BrandSummary != "" || req.BrandQuestion != "" {
+ return true
+ }
+ if len(req.SupplementalQuestions) > 0 {
return true
}
if len(req.Keywords) > 0 || len(req.Competitors) > 0 {
@@ -738,7 +769,10 @@ func hasOutlineContext(req OutlineTaskRequest) bool {
if req.Title == "" || len(req.OutlineSections) == 0 {
return false
}
- if req.BrandName != "" || req.Website != "" || req.BrandSummary != "" || req.KeyPoints != "" {
+ if req.BrandName != "" || req.Website != "" || req.BrandSummary != "" || req.KeyPoints != "" || req.BrandQuestion != "" {
+ return true
+ }
+ if len(req.SupplementalQuestions) > 0 {
return true
}
if len(req.Keywords) > 0 || len(req.Competitors) > 0 {
@@ -835,13 +869,18 @@ func buildOutlineAIPointMeteredText(req OutlineTaskRequest) string {
}
func analyzePromptParams(templateKey, templateName string, req AnalyzeTaskRequest) map[string]interface{} {
+ keywords := buildQuestionKeywordContext(req.BrandQuestion, nil, 12)
return map[string]interface{}{
"template_key": templateKey,
"template_name": templateName,
"locale": req.Locale,
"brand_name": req.BrandName,
"official_website": req.Website,
+ "brand_question": req.BrandQuestion,
+ "primary_question": req.BrandQuestion,
"input_params": req.InputParams,
+ "keywords": keywords,
+ "primary_keyword": req.BrandQuestion,
"existing_keywords": req.ExistingKeywords,
"existing_competitors": req.ExistingCompetitors,
}
@@ -855,6 +894,8 @@ func titlePromptParams(templateKey, templateName string, req TitleTaskRequest) m
"brand_name": req.BrandName,
"official_website": req.Website,
"brand_summary": req.BrandSummary,
+ "brand_question": req.BrandQuestion,
+ "primary_question": req.BrandQuestion,
"input_params": req.InputParams,
"keywords": req.Keywords,
"competitors": req.Competitors,
@@ -863,7 +904,10 @@ func titlePromptParams(templateKey, templateName string, req TitleTaskRequest) m
}
mergePromptParams(params, req.InputParams)
- if len(req.Keywords) > 0 {
+ if req.BrandQuestion != "" {
+ params["primary_keyword"] = req.BrandQuestion
+ params["keyword_count"] = 1
+ } else if len(req.Keywords) > 0 {
params["primary_keyword"] = req.Keywords[0]
params["keyword_count"] = len(req.Keywords)
}
@@ -892,24 +936,34 @@ func titlePromptParams(templateKey, templateName string, req TitleTaskRequest) m
func outlinePromptParams(templateKey, templateName string, req OutlineTaskRequest) map[string]interface{} {
params := map[string]interface{}{
- "template_key": templateKey,
- "template_name": templateName,
- "locale": req.Locale,
- "title": req.Title,
- "brand_name": req.BrandName,
- "official_website": req.Website,
- "brand_summary": req.BrandSummary,
- "input_params": req.InputParams,
- "keywords": req.Keywords,
- "competitors": req.Competitors,
- "competitor_count": len(req.Competitors),
- "outline_sections": req.OutlineSections,
- "key_points": req.KeyPoints,
- "current_year": time.Now().Year(),
+ "template_key": templateKey,
+ "template_name": templateName,
+ "locale": req.Locale,
+ "title": req.Title,
+ "brand_name": req.BrandName,
+ "official_website": req.Website,
+ "brand_summary": req.BrandSummary,
+ "brand_question": req.BrandQuestion,
+ "primary_question": req.BrandQuestion,
+ "supplemental_questions": req.SupplementalQuestions,
+ "input_params": req.InputParams,
+ "keywords": req.Keywords,
+ "competitors": req.Competitors,
+ "competitor_count": len(req.Competitors),
+ "outline_sections": req.OutlineSections,
+ "key_points": req.KeyPoints,
+ "current_year": time.Now().Year(),
}
mergePromptParams(params, req.InputParams)
- if len(req.Keywords) > 0 {
+ if req.BrandQuestion != "" {
+ params["primary_keyword"] = req.BrandQuestion
+ params["keyword_count"] = len(buildQuestionKeywordContext(
+ req.BrandQuestion,
+ req.SupplementalQuestions,
+ 12,
+ ))
+ } else if len(req.Keywords) > 0 {
params["primary_keyword"] = req.Keywords[0]
params["keyword_count"] = len(req.Keywords)
}
diff --git a/server/internal/tenant/app/template_prompt.go b/server/internal/tenant/app/template_prompt.go
index 39d80d8..fcaf109 100644
--- a/server/internal/tenant/app/template_prompt.go
+++ b/server/internal/tenant/app/template_prompt.go
@@ -67,7 +67,9 @@ func buildGenerationKnowledgeQuery(params map[string]interface{}) string {
appendValue(stringValue(params["product_name"]))
appendValue(stringValue(params["subject"]))
appendValue(stringValue(params["brand_name"]))
+ appendValue(stringValue(params["brand_question"]))
appendValue(stringValue(params["primary_keyword"]))
+ appendValue(formatPromptValue(params["supplemental_questions"]))
appendValue(stringValue(params["key_points"]))
appendValue(stringValue(params["review_intro_hook"]))
@@ -187,7 +189,10 @@ func buildPromptContext(params map[string]interface{}) string {
"product_name",
"subject",
"brand_name",
+ "brand_question",
+ "primary_question",
"primary_keyword",
+ "supplemental_questions",
"brand",
"category",
"count",
diff --git a/server/internal/tenant/app/template_prompt_test.go b/server/internal/tenant/app/template_prompt_test.go
index 308a9be..494305b 100644
--- a/server/internal/tenant/app/template_prompt_test.go
+++ b/server/internal/tenant/app/template_prompt_test.go
@@ -20,7 +20,7 @@ func TestBuildPromptContextUsesChineseLabels(t *testing.T) {
"- 语言: zh-CN",
"- 标题: 测试标题",
"- 品牌名: Rankly",
- "- 核心关键词: GEO 排名",
+ "- 品牌主问题: GEO 排名",
"- 已选段落: 引言 > 结论",
} {
if !strings.Contains(context, expected) {
diff --git a/server/internal/tenant/prompts/loader_test.go b/server/internal/tenant/prompts/loader_test.go
index aa52a1b..0a2d2f7 100644
--- a/server/internal/tenant/prompts/loader_test.go
+++ b/server/internal/tenant/prompts/loader_test.go
@@ -47,7 +47,7 @@ func TestPlatformTemplateSeedsInjectPromptConfig(t *testing.T) {
if !strings.Contains(analyzePrompt, "推荐类文章做品牌与竞品分析") {
t.Fatalf("analyze_prompt_template = %q, want yaml-backed analyze prompt", analyzePrompt)
}
- if !strings.Contains(analyzePrompt, "全屋定制哪家好") || !strings.Contains(analyzePrompt, "搜索查询词") {
+ if !strings.Contains(analyzePrompt, "全屋定制哪家好") || !strings.Contains(analyzePrompt, "搜索问题候选") {
t.Fatalf("analyze_prompt_template = %q, want question-like long-tail query guidance", analyzePrompt)
}
if !strings.Contains(titlePrompt, "推荐类文章生成 5 个候选标题") {
@@ -66,10 +66,10 @@ func TestAnalyzeFallbackPromptPrefersQuestionLikeQueries(t *testing.T) {
prompt := AnalyzeFallbackPrompt(`{"brand_name":"测试品牌","input_params":{"keyword":"全屋定制"}}`)
for _, want := range []string{
- "真实用户会直接搜索的问题型长尾词",
- "哪家好",
- "最多返回 6 个竞品和 10 个关键词",
- } {
+ "真实用户会直接搜索的问题型长尾词",
+ "哪家好",
+ "最多返回 6 个竞品和 10 个搜索问题候选",
+ } {
if !strings.Contains(prompt, want) {
t.Fatalf("AnalyzeFallbackPrompt() missing %q in prompt:\n%s", want, prompt)
}
diff --git a/server/internal/tenant/prompts/template_seeds.go b/server/internal/tenant/prompts/template_seeds.go
index b37daca..6ccce9f 100644
--- a/server/internal/tenant/prompts/template_seeds.go
+++ b/server/internal/tenant/prompts/template_seeds.go
@@ -44,7 +44,7 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
},
"wizard": {
"steps": {
- "basic": { "title": "基本信息", "description": "品牌、关键词与竞品上下文" },
+ "basic": { "title": "基本信息", "description": "品牌、品牌问题与竞品上下文" },
"structure": { "title": "文章结构", "description": "标题方案、结构组合与重点补充" },
"generate": { "title": "生成文章", "description": "确认参数并进入生成队列" }
},
@@ -53,11 +53,11 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
"cards": {
"brand": {
"title": "品牌信息",
- "hint": "品牌可手动输入,也可从品牌库带入。AI 会结合品牌、官网和模板字段补齐关键词与竞品。"
+ "hint": "品牌可手动输入,也可从品牌库带入。AI 会结合品牌、官网和品牌问题补齐摘要与竞品。"
},
"keywords": {
- "title": "关键词",
- "hint": "关键词可以手动改写,AI 分析结果会自动补充到这里,后续标题生成会以当前版本为准。"
+ "title": "品牌问题",
+ "hint": "品牌主问题必选且只能选一个;补充覆盖问题可选,最多 3 个,只进入文章主体和结尾点题。"
},
"template_fields": {
"title": "模板字段",
@@ -73,7 +73,7 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
"cards": {
"choose_title": {
"title": "选择文章标题",
- "hint": "AI 会结合当前关键词和竞品上下文给出 5 个更适合的标题候选,你可以直接选择或改写。"
+ "hint": "AI 会结合品牌主问题和竞品上下文给出 5 个更适合的标题候选,你可以直接选择或改写。"
},
"outline": {
"title": "文章结构",
@@ -109,7 +109,7 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
{ "key": "intro", "label": "引言", "default": true },
{ "key": "site_list", "label": "网站列表", "default": true },
{ "key": "key_points", "label": "文章关键要点", "default": true },
- { "key": "what_is_keyword", "label_template": "什么是{{primary_keyword}}", "default": false },
+ { "key": "what_is_question", "label_template": "围绕{{brand_question}}展开", "default": false },
{ "key": "features", "label": "特点", "default": false },
{ "key": "pros_cons", "label": "优缺点", "default": false },
{ "key": "audience", "label": "适合人群", "default": false },
@@ -141,11 +141,11 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
"cards": {
"brand": {
"title": "品牌信息",
- "hint": "可直接输入品牌,也可从品牌库选择。AI 会补充适合评测文章的关键词和品牌背景。"
+ "hint": "可直接输入品牌,也可从品牌库选择。AI 会结合品牌问题补充适合评测文章的品牌背景。"
},
"keywords": {
- "title": "关键词",
- "hint": "关键词会影响标题和正文的评测切入点,可手动修改。"
+ "title": "品牌问题",
+ "hint": "品牌主问题控制标题和主线;补充覆盖问题只进入文章主体与结尾点题。"
},
"competitors": {
"visible": false
@@ -222,7 +222,7 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
},
"wizard": {
"steps": {
- "basic": { "title": "基本信息", "description": "品牌与关键词背景信息" },
+ "basic": { "title": "基本信息", "description": "品牌与品牌问题背景信息" },
"structure": { "title": "文章结构", "description": "标题方案、研究结构与重点补充" },
"generate": { "title": "生成文章", "description": "确认参数并进入生成队列" }
},
@@ -234,8 +234,8 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
"hint": "如研究对象与品牌有关,可补充品牌与官网信息帮助 AI 建立上下文。"
},
"keywords": {
- "title": "关键词",
- "hint": "关键词会影响报告的核心视角、结论表达和标题风格。"
+ "title": "品牌问题",
+ "hint": "品牌主问题控制报告核心视角;补充覆盖问题只进入主体覆盖和结尾点题。"
},
"competitors": {
"visible": false
@@ -305,7 +305,7 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
},
"wizard": {
"steps": {
- "basic": { "title": "基本信息", "description": "品牌、关键词与搜索意图上下文" },
+ "basic": { "title": "基本信息", "description": "品牌、品牌问题与搜索意图上下文" },
"structure": { "title": "文章结构", "description": "标题方案、结构组合与重点补充" },
"generate": { "title": "生成文章", "description": "确认参数并进入生成队列" }
},
@@ -317,8 +317,8 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
"hint": "品牌和官网会帮助 AI 理解搜索对象,也便于后续沉淀到品牌库。"
},
"keywords": {
- "title": "关键词",
- "hint": "这里的关键词会直接参与标题与正文的搜索意图组织。"
+ "title": "品牌问题",
+ "hint": "品牌主问题参与标题与主线;补充覆盖问题只进入正文主体和结尾点题。"
},
"template_fields": {
"title": "模板字段",
diff --git a/server/internal/tenant/transport/monitoring_handler.go b/server/internal/tenant/transport/monitoring_handler.go
index 309f76a..a9e1354 100644
--- a/server/internal/tenant/transport/monitoring_handler.go
+++ b/server/internal/tenant/transport/monitoring_handler.go
@@ -18,6 +18,7 @@ type MonitoringHandler struct {
type monitoringCollectNowRequest struct {
KeywordID *int64 `json:"keyword_id"`
+ QuestionID *int64 `json:"question_id"`
PlatformIDs []string `json:"platform_ids"`
Preempt *bool `json:"preempt"`
WaitForFirstDispatch *bool `json:"wait_for_first_dispatch"`
@@ -43,6 +44,12 @@ func (h *MonitoringHandler) DashboardComposite(c *gin.Context) {
return
}
+ questionID, err := parseOptionalInt64Pointer(c.Query("question_id"))
+ if err != nil {
+ response.Error(c, response.ErrBadRequest(40031, "invalid_question_id", "question_id must be a number"))
+ return
+ }
+
days, err := parseOptionalInt(c.Query("days"))
if err != nil {
response.Error(c, response.ErrBadRequest(40031, "invalid_days", "days must be a number"))
@@ -51,7 +58,7 @@ func (h *MonitoringHandler) DashboardComposite(c *gin.Context) {
aiPlatformID := parseOptionalStringPointer(c.Query("ai_platform_id"))
- data, svcErr := h.svc.DashboardComposite(c.Request.Context(), brandID, keywordID, days, c.Query("business_date"), aiPlatformID)
+ data, svcErr := h.svc.DashboardComposite(c.Request.Context(), brandID, keywordID, questionID, days, c.Query("business_date"), aiPlatformID)
if svcErr != nil {
response.Error(c, svcErr)
return
@@ -78,9 +85,15 @@ func (h *MonitoringHandler) CitationSummary(c *gin.Context) {
return
}
+ questionID, err := parseOptionalInt64Pointer(c.Query("question_id"))
+ if err != nil {
+ response.Error(c, response.ErrBadRequest(40031, "invalid_question_id", "question_id must be a number"))
+ return
+ }
+
aiPlatformID := parseOptionalStringPointer(c.Query("ai_platform_id"))
- data, svcErr := h.svc.CitationSummary(c.Request.Context(), days, brandID, keywordID, c.Query("business_date"), aiPlatformID)
+ data, svcErr := h.svc.CitationSummary(c.Request.Context(), days, brandID, keywordID, questionID, c.Query("business_date"), aiPlatformID)
if svcErr != nil {
response.Error(c, svcErr)
return
@@ -142,7 +155,7 @@ func (h *MonitoringHandler) CollectNow(c *gin.Context) {
targetClientID = &parsed
}
- data, svcErr := h.svc.CollectNow(c.Request.Context(), brandID, req.KeywordID, app.MonitoringCollectNowOptions{
+ data, svcErr := h.svc.CollectNow(c.Request.Context(), brandID, req.KeywordID, req.QuestionID, app.MonitoringCollectNowOptions{
PlatformIDs: req.PlatformIDs,
Preempt: req.Preempt == nil || *req.Preempt,
WaitForFirstDispatch: req.WaitForFirstDispatch != nil && *req.WaitForFirstDispatch,
diff --git a/server/internal/tenant/transport/question_expansion_handler.go b/server/internal/tenant/transport/question_expansion_handler.go
index 90d7f90..ffec6a7 100644
--- a/server/internal/tenant/transport/question_expansion_handler.go
+++ b/server/internal/tenant/transport/question_expansion_handler.go
@@ -36,6 +36,24 @@ func (h *QuestionExpansionHandler) CombinationPreview(c *gin.Context) {
response.Success(c, data)
}
+func (h *QuestionExpansionHandler) CombinationFill(c *gin.Context) {
+ brandID, ok := parseBrandIDParam(c)
+ if !ok {
+ return
+ }
+ var req app.QuestionCombinationFillRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
+ return
+ }
+ data, err := h.svc.FillQuestionCombination(c.Request.Context(), brandID, c.ClientIP(), 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 {
diff --git a/server/internal/tenant/transport/router.go b/server/internal/tenant/transport/router.go
index 0d70f3b..520a98e 100644
--- a/server/internal/tenant/transport/router.go
+++ b/server/internal/tenant/transport/router.go
@@ -190,6 +190,7 @@ func RegisterRoutes(app *bootstrap.App) {
brands.GET("/:id/questions", brandHandler.ListQuestions)
brands.POST("/:id/questions", brandHandler.CreateQuestion)
brands.POST("/:id/questions/combination-preview", questionExpansionHandler.CombinationPreview)
+ brands.POST("/:id/questions/combination-fill", questionExpansionHandler.CombinationFill)
brands.POST("/:id/questions/ai-distill", questionExpansionHandler.AIDistill)
brands.POST("/:id/questions/classify-metadata", questionExpansionHandler.ClassifyMetadata)
brands.POST("/:id/questions/materialize", questionExpansionHandler.Materialize)