diff --git a/apps/admin-web/src/components/AiWaitingModal.vue b/apps/admin-web/src/components/AiWaitingModal.vue new file mode 100644 index 0000000..805aedd --- /dev/null +++ b/apps/admin-web/src/components/AiWaitingModal.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index 4e77655..b902bc7 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -151,6 +151,8 @@ const enUS = { template_outline_generate: 'AI Outline Generation', kol_prompt_generate: 'KOL Prompt Generation', kol_prompt_optimize: 'KOL Prompt Optimize', + question_distill: 'AI Candidate Question Generation', + question_combination_fill: 'Expansion Tool AI Fill', }, aiUsageStatus: { pending: 'Processing', @@ -243,9 +245,20 @@ const enUS = { aiPoints: { balance: 'Available Points', baseChars: 'Base Characters', + usageTypeFallback: 'AI Service Call', ledgerTitle: 'Usage Ledger', ledgerLimit: 'Latest {limit} records', ledgerTotal: '{total} records', + refundedHelp: + 'The system deducted points first. After the task failed, the points were returned automatically, so net usage is 0.', + amount: { + netZero: 'Net 0', + zero: '0', + pendingFlow: '{points} pts reserved', + completedFlow: '{points} pts charged', + refundedFlow: '{points} pts deducted, then returned', + failedFlow: 'No charge', + }, table: { usageType: 'Usage', points: 'Points', @@ -431,10 +444,11 @@ const enUS = { }, tracking: { brandPlaceholder: 'Select brand', - keywordPlaceholder: 'Select keyword', + keywordPlaceholder: 'Select question set', + allQuestionSets: 'All question sets', collectNow: 'Collect Now', collectBrandRequired: 'Select a brand before collecting.', - collectKeywordRequired: 'Select a keyword before collecting the current question set.', + collectKeywordRequired: 'Select a question set before collecting.', collectTodayOnly: "Collect Now only supports today's data. Switch back to today to continue.", collectClientChecking: "Checking whether the current account's desktop client is online.", collectClientOnlineRequired: @@ -458,8 +472,9 @@ const enUS = { platformMatrixTitle: 'Platform Comparison Matrix', hotQuestions: 'Hot Questions', hotQuestionsTitle: 'Hot Questions', - hotQuestionsHint: 'Questions come from the brand library under the selected keyword.', - questionSearchPlaceholder: 'Search questions under the selected keyword', + hotQuestionsHint: + 'Choose all question sets or one specific question. Selecting one question limits display and collection to that question.', + questionSearchPlaceholder: 'Search questions in the selected scope', questionDetailEyebrow: 'Question Detail', questionDetailCollectedAt: 'Collected at {time}', questionDetailFallback: 'Question Detail', @@ -813,6 +828,9 @@ const enUS = { website: 'Official website', brandSummary: 'AI brand summary', keywords: 'Keywords', + brandQuestions: 'Brand questions', + primaryQuestion: 'Primary brand question', + supplementalQuestions: 'Supplemental questions', competitors: 'Competitors', chooseTitle: 'Choose a title', customTitle: 'Custom title', @@ -829,11 +847,17 @@ const enUS = { hints: { brandContext: 'Optional. Selecting a brand pulls in keywords and competitor context.', brandFlow: - 'You can pick a brand from the library or type one manually. AI analysis will expand keywords, competitors, and title options.', + 'You can pick a brand from the library or type one manually. After you choose a brand question, AI will prepare the summary, competitors, and title options.', brandSummaryPlaceholder: 'After analysis, AI will summarize the brand angle, positioning, and content opportunities here.', keywords: 'Type keywords directly or reuse brand-library keywords. AI results will be merged into this list.', + brandQuestions: + 'The primary question controls the article spine; supplemental questions broaden nearby search intent.', + supplementalQuestions: + 'Choose up to 3 so the article covers related intent without drifting.', + questionEmpty: 'No questions are available for this brand yet.', + selectBrandFirst: 'Choose a brand from the library first.', competitors: 'Competitors stay editable. You can add rows manually; saving to the competitor library is only available after choosing a library brand.', competitorEmptyEditable: @@ -891,6 +915,8 @@ const enUS = { brandName: 'Enter a brand name, or refine the selected library brand', website: 'Official website, optional', keywords: 'Type keywords and press Enter', + primaryQuestion: 'Choose one primary brand question', + supplementalQuestions: 'Optional, choose up to 3 supplemental questions', customTitle: 'Override the AI title here if needed', customOutline: 'Add another section, for example “Buying advice”', keyPoints: 'Add any extra instructions, key angles, or constraints for the article', @@ -904,32 +930,33 @@ const enUS = { aiBanner: { title: 'One-click AI analysis', defaultDescription: - 'AI will complete the brand summary, find core keywords, and extract competitor context.', + 'AI will use the brand question to complete the brand summary and extract competitor context.', productReviewDescription: - 'AI will organize the product context, extract review keywords, and suggest comparable alternatives.', + 'AI will use the brand question to organize the product context and suggest comparable alternatives.', researchReportDescription: - 'AI will organize the topic context, extract research keywords, and suggest reference targets.', + 'AI will use the brand question to organize the topic context and suggest reference targets.', }, assist: { - analyzeTitle: 'AI is analyzing keywords and competitors. Please wait…', - analyzeTitleNoCompetitors: 'AI is analyzing keywords and context. Please wait…', + analyzeTitle: 'AI is analyzing brand questions and competitors. Please wait…', + analyzeTitleNoCompetitors: 'AI is analyzing brand questions and context. Please wait…', titleTitle: 'AI is generating titles. Please wait…', outlineTitle: 'AI is generating the article outline. Please wait…', analyzeStages: { brand: 'Analyzing the brand context…', keywords: 'Extracting keyword opportunities…', + questions: 'Organizing brand questions…', competitors: 'Mapping competitors and websites…', context: 'Organizing the content context…', }, titleStages: { context: 'Preparing the current brief…', - strategy: 'Generating title directions from keywords and competitors…', - strategyNoCompetitors: 'Generating title directions from the keywords and brief…', + strategy: 'Generating title directions from brand questions and competitors…', + strategyNoCompetitors: 'Generating title directions from the brand question and brief…', finalize: 'Returning 5 title options…', }, outlineStages: { - context: 'Preparing the title, keywords, and competitor context…', - contextNoCompetitors: 'Preparing the title, keywords, and content context…', + context: 'Preparing the title, brand questions, and competitor context…', + contextNoCompetitors: 'Preparing the title, brand questions, and content context…', structure: 'Generating the article outline from the selected structure…', finalize: 'Returning the editable outline result…', }, @@ -937,6 +964,8 @@ const enUS = { messages: { requiredField: 'Please fill in {field} first.', missingBrand: 'Choose a brand from the library or enter a brand name first.', + selectBrandBeforeQuestion: 'Choose a brand from the library before selecting a question.', + missingPrimaryQuestion: 'Choose one primary brand question.', missingTitle: 'Please choose or enter an article title.', missingOutline: 'Please select at least one outline section.', missingGeneratedOutline: 'Please generate and confirm the article outline first.', @@ -1355,10 +1384,27 @@ const enUS = { coreRequired: 'Core is required', industryRequired: 'Industry is required', }, + aiFill: { + button: 'AI fill', + success: 'Word columns filled. You can keep editing.', + charged: 'Word columns filled. This used {count} AI point.', + waitingTitle: 'AI is filling the expansion tool. Please wait...', + waitingStages: { + region: 'Detecting city, province capital, and macro region...', + generate: 'Analyzing brand context and high-frequency intent...', + refine: 'Refining short combinable keyword groups...', + }, + }, ai: { note: 'AI expansion consumes 1 AI point. Failed generations are refunded automatically.', seedTopic: 'Seed topic', generate: 'Generate with AI', + waitingTitle: 'AI is generating candidate questions. Please wait...', + waitingStages: { + context: 'Organizing brand and topic context...', + generate: 'Generating high-intent candidate questions...', + refine: 'Deduplicating and filtering savable questions...', + }, }, batch: { label: 'One question per line', @@ -1370,6 +1416,7 @@ const enUS = { messages: { partialSaved: 'Saved {created}; skipped {skipped}.', limitReached: 'This account allows up to {limit} questions shared across all brands.', + selectionLimitReached: 'This plan allows up to {limit} questions. Selection limit reached.', truncated: 'Candidates exceeded the limit and were truncated to 500.', cacheHit: 'Used the most recent AI expansion result.', aiCharged: 'This AI expansion consumed {count} AI point.', @@ -1378,8 +1425,6 @@ const enUS = { emptyInput: 'Enter question content first.', noSelection: 'Select at least one candidate.', noValidQuestions: 'No valid questions can be saved. Adjust candidates and try again.', - invalidCandidate: - 'This candidate cannot be selected until it is no longer too short, duplicated, or invalid.', }, page: { title: 'New question', diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index fa4be21..b5ef5f2 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -147,6 +147,8 @@ const zhCN = { template_outline_generate: "AI 生成大纲", kol_prompt_generate: "KOL 模版生成", kol_prompt_optimize: "KOL 模版优化", + question_distill: "AI 生成候选问题", + question_combination_fill: "拓词工具 AI 填词", }, aiUsageStatus: { pending: "处理中", @@ -232,9 +234,19 @@ const zhCN = { aiPoints: { balance: "可用点数", baseChars: "基准字数", + usageTypeFallback: "AI 服务调用", ledgerTitle: "消耗流水", ledgerLimit: "最近 {limit} 条记录", ledgerTotal: "共 {total} 条记录", + refundedHelp: "系统已先扣除点数;任务失败后自动退回,本次净消耗为 0。", + amount: { + netZero: "净消耗 0", + zero: "0", + pendingFlow: "已预扣 {points} 点", + completedFlow: "已扣除 {points} 点", + refundedFlow: "已扣除 {points} 点后退回", + failedFlow: "未产生扣费", + }, table: { usageType: "使用场景", points: "点数", @@ -414,10 +426,11 @@ const zhCN = { }, tracking: { brandPlaceholder: "选择品牌", - keywordPlaceholder: "选择关键词", + keywordPlaceholder: "选择问题集", + allQuestionSets: "全部问题集", collectNow: "立即采集", collectBrandRequired: "请先选择品牌,再发起采集", - collectKeywordRequired: "请先选择关键词,再立即采集当前问题", + collectKeywordRequired: "请选择问题集后再发起采集", collectTodayOnly: "立即采集只支持今天的数据,请切回今天后再操作", collectClientChecking: "正在检查当前账号的桌面客户端状态", collectClientOnlineRequired: "当前登录账号的桌面客户端未在线,暂时不能立即采集", @@ -437,8 +450,8 @@ const zhCN = { platformMatrixTitle: "平台对比矩阵", hotQuestions: "Hot Questions", hotQuestionsTitle: "高频问题", - hotQuestionsHint: "高频问题来自品牌库当前关键词下的全部问题。", - questionSearchPlaceholder: "搜索当前关键词下的问题", + hotQuestionsHint: "可选择全部问题集或具体单个问题;选择单个问题时只展示并采集该问题。", + questionSearchPlaceholder: "搜索当前范围下的问题", questionDetailEyebrow: "Question Detail", questionDetailCollectedAt: "采集时间 {time}", questionDetailFallback: "问题详情", @@ -781,6 +794,9 @@ const zhCN = { website: "官网地址", brandSummary: "AI 品牌摘要", keywords: "关键词", + brandQuestions: "品牌问题", + primaryQuestion: "品牌主问题", + supplementalQuestions: "补充覆盖问题", competitors: "竞品列表", chooseTitle: "选择文章标题", customTitle: "自定义标题", @@ -797,9 +813,13 @@ const zhCN = { }, hints: { brandContext: "可选。选择后会自动带出关键词与竞品信息。", - brandFlow: "品牌可直接输入,也可以从品牌库带入。点击分析后,AI 会补充关键词、竞品和标题建议。", + brandFlow: "品牌可直接输入,也可以从品牌库带入。选择品牌问题后,AI 会补充品牌摘要、竞品和标题建议。", brandSummaryPlaceholder: "AI 分析后会在这里补充品牌简介、差异点和适合切入的内容角度。", keywords: "支持直接输入,也支持复用品牌词库里的关键词。AI 分析结果会自动补充到这里。", + brandQuestions: "主问题决定文章主线,补充覆盖问题只用于扩展相关搜索意图。", + supplementalQuestions: "最多选择 3 个,用于覆盖相邻搜索问题,避免文章主题发散。", + questionEmpty: "当前品牌还没有可选问题", + selectBrandFirst: "请先选择品牌库对象", competitors: "竞品支持手动编辑、追加;只有从品牌库选择品牌后,才可以收藏到品牌词库。AI 分析会尽量补齐候选竞品。", competitorEmptyEditable: "还没有竞品,先手动添加一条或点击分析让 AI 自动补齐。", templateFields: "这里保留当前模板自己的补充字段,用来约束 AI 生成角度。", @@ -852,6 +872,8 @@ const zhCN = { brandName: "输入品牌名,或在选择品牌后继续微调", website: "输入官网地址,选填", keywords: "输入关键词后回车,可多选", + primaryQuestion: "选择一个品牌主问题", + supplementalQuestions: "可选,最多选择 3 个补充问题", customTitle: "如需覆盖 AI 标题,可在这里直接输入", customOutline: "补充一个额外结构,例如“选购建议”", keyPoints: "告诉 AI 还需要强调哪些要点、风格或限制条件", @@ -864,30 +886,31 @@ const zhCN = { promptSealed: "Prompt 已封装", aiBanner: { title: "一键智能分析", - defaultDescription: "AI 将自动补充品牌简介、发掘核心关键词并智能提取竞品信息", - productReviewDescription: "AI 将自动梳理产品背景、提炼评测关键词并生成同类替代方案参考", - researchReportDescription: "AI 将自动梳理主题背景、提炼研究关键词并生成参照对象参考", + defaultDescription: "AI 将基于品牌问题补充品牌简介,并智能提取竞品信息", + productReviewDescription: "AI 将基于品牌问题梳理产品背景,并生成同类替代方案参考", + researchReportDescription: "AI 将基于品牌问题梳理主题背景,并生成参照对象参考", }, assist: { - analyzeTitle: "AI 正在分析关键词和竞品,请稍后…", - analyzeTitleNoCompetitors: "AI 正在分析关键词和评测上下文,请稍后…", + analyzeTitle: "AI 正在分析品牌问题和竞品,请稍后…", + analyzeTitleNoCompetitors: "AI 正在分析品牌问题和评测上下文,请稍后…", titleTitle: "AI 正在生成标题,请稍后…", outlineTitle: "AI 正在生成文章大纲,请稍后…", analyzeStages: { brand: "品牌信息分析中…", keywords: "关键词提炼中…", + questions: "品牌问题整理中…", competitors: "竞品与官网信息补充中…", context: "评测上下文整理中…", }, titleStages: { context: "正在整理当前内容上下文…", - strategy: "正在结合关键词和竞品生成标题策略…", - strategyNoCompetitors: "正在结合关键词生成标题策略…", + strategy: "正在结合品牌问题和竞品生成标题策略…", + strategyNoCompetitors: "正在结合品牌问题生成标题策略…", finalize: "正在输出 5 个标题候选…", }, outlineStages: { - context: "正在整理标题、关键词和竞品上下文…", - contextNoCompetitors: "正在整理标题、关键词和评测上下文…", + context: "正在整理标题、品牌问题和竞品上下文…", + contextNoCompetitors: "正在整理标题、品牌问题和评测上下文…", structure: "正在根据已选结构生成文章大纲…", finalize: "正在输出可编辑的大纲结果…", }, @@ -895,6 +918,8 @@ const zhCN = { messages: { requiredField: "请先填写 {field}", missingBrand: "请先选择品牌库对象或输入品牌名称", + selectBrandBeforeQuestion: "请先从品牌库选择品牌后再选择品牌问题", + missingPrimaryQuestion: "请选择一个品牌主问题", missingTitle: "请先选择或输入文章标题", missingReviewIntroHook: "请先选择一个评测引言钩子", missingOutline: "请至少选择一个文章结构段落", @@ -1287,10 +1312,27 @@ const zhCN = { coreRequired: "核心不能为空", industryRequired: "行业不能为空", }, + aiFill: { + button: "AI 填词", + success: "已填入拓词词组,可继续编辑。", + charged: "已填入拓词词组,本次消耗 {count} 个 AI 点。", + waitingTitle: "AI 正在填充拓词工具,请稍后…", + waitingStages: { + region: "正在识别所在地域并匹配城市、省会与大区…", + generate: "正在分析品牌和高频搜索意图…", + refine: "正在整理可组合的核心词组…", + }, + }, ai: { note: "AI 扩展会消耗 1 个 AI 点;生成失败时系统会自动退还。", seedTopic: "扩展主题", generate: "AI 生成候选", + waitingTitle: "AI 正在生成候选问题,请稍后…", + waitingStages: { + context: "正在整理品牌与主题上下文…", + generate: "正在生成高意图候选问题…", + refine: "正在去重并筛选可保存问题…", + }, }, batch: { label: "每行一个问题", @@ -1302,6 +1344,7 @@ const zhCN = { messages: { partialSaved: "已保存 {created} 条,跳过 {skipped} 条", limitReached: "当前账号最多可维护 {limit} 个问题,所有品牌共用该额度", + selectionLimitReached: "当前套餐最多可维护 {limit} 个问题,已达到可选上限", truncated: "候选超过上限,已截断为 500 条。", cacheHit: "已使用最近一次 AI 扩展结果。", aiCharged: "本次 AI 扩展已消耗 {count} 个 AI 点。", @@ -1310,7 +1353,6 @@ const zhCN = { emptyInput: "请先输入问题内容", noSelection: "请至少选择 1 条候选", noValidQuestions: "没有可保存的问题,请调整候选后重试", - invalidCandidate: "该候选暂不可选择,请先处理过短、重复或非问题文本", }, page: { title: "新建问题", diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 181e42d..1a0202c 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -92,6 +92,8 @@ import type { PublishRecord, Question, QuestionCandidateResult, + QuestionCombinationFillRequest, + QuestionCombinationFillResult, QuestionCombinationRequest, QuestionDistillRequest, QuestionRequest, @@ -150,6 +152,7 @@ const rawBaseURL = import.meta.env.VITE_API_BASE_URL ?? '' const baseURL = normalizeApiBaseURL(rawBaseURL) const generationStreamEnabled = import.meta.env.VITE_GENERATION_STREAM_ENABLED === 'true' const accessRefreshSkewMs = 2 * 60 * 1000 +const aiDistillRequestTimeoutMs = 45_000 const publicClient = createApiClient({ baseURL }) let storedRefreshPromise: Promise | null = null @@ -1049,10 +1052,18 @@ export const brandsApi = { payload, ) }, + fillQuestionCombination(brandId: number, payload: QuestionCombinationFillRequest) { + return apiClient.post( + `/api/tenant/brands/${brandId}/questions/combination-fill`, + payload, + { timeout: aiDistillRequestTimeoutMs }, + ) + }, distillQuestions(brandId: number, payload: QuestionDistillRequest) { return apiClient.post( `/api/tenant/brands/${brandId}/questions/ai-distill`, payload, + { timeout: aiDistillRequestTimeoutMs }, ) }, classifyQuestions(brandId: number, payload: ClassifyQuestionsRequest) { @@ -1219,6 +1230,7 @@ export const monitoringApi = { dashboardComposite(params: { brand_id?: number keyword_id?: number | null + question_id?: number | null days?: number business_date?: string ai_platform_id?: string @@ -1234,6 +1246,7 @@ export const monitoringApi = { days?: number brand_id?: number keyword_id?: number | null + question_id?: number | null business_date?: string ai_platform_id?: string }) { @@ -1259,10 +1272,13 @@ export const monitoringApi = { { params }, ) }, - collectNow(brandId: number, payload?: { keyword_id?: number | null; platform_ids?: string[] }) { + collectNow( + brandId: number, + payload?: { keyword_id?: number | null; question_id?: number | null; platform_ids?: string[] }, + ) { return apiClient.post< MonitoringCollectNowResponse, - { keyword_id?: number | null; platform_ids?: string[] } + { keyword_id?: number | null; question_id?: number | null; platform_ids?: string[] } >(`/api/tenant/monitoring/brands/${brandId}/collect-now`, payload ?? {}) }, } diff --git a/apps/admin-web/src/lib/errors.ts b/apps/admin-web/src/lib/errors.ts index d4817f0..00e5e98 100644 --- a/apps/admin-web/src/lib/errors.ts +++ b/apps/admin-web/src/lib/errors.ts @@ -97,6 +97,7 @@ const errorMessageMap: Record = { publisher_plugin_unknown_error: '浏览器插件调用失败,请稍后重试', installation_offline: '桌面客户端未在线,请打开桌面客户端后重试', network_error: '网络连接失败,请稍后重试', + request_timeout: '请求超时,AI 生成耗时较长,请稍后重试或稍候再查看结果', unknown_error: '发生未知错误', } @@ -136,13 +137,27 @@ export function isHandledAuthError(error: unknown): boolean { ) } +const TIMEOUT_MESSAGE_PATTERN = /^timeout of \d+ms exceeded$/i + +function translateRawErrorMessage(raw: string): string | null { + const mapped = errorMessageMap[raw] + if (mapped) { + return mapped + } + if (TIMEOUT_MESSAGE_PATTERN.test(raw)) { + return errorMessageMap.request_timeout + } + return null +} + export function formatError(error: unknown): string { if (isHandledAuthError(error)) { return '登录已过期,请重新登录' } if (error instanceof ApiClientError) { - const message = errorMessageMap[error.message] ?? error.message.replaceAll('_', ' ') + const translated = translateRawErrorMessage(error.message) + const message = translated ?? error.message.replaceAll('_', ' ') if (isAiPointsInsufficient(error)) { return message } @@ -150,7 +165,7 @@ export function formatError(error: unknown): string { } if (error instanceof Error) { - return errorMessageMap[error.message] ?? error.message + return translateRawErrorMessage(error.message) ?? error.message } return '发生未知错误' diff --git a/apps/admin-web/src/views/AIPointUsageView.vue b/apps/admin-web/src/views/AIPointUsageView.vue index 2283cec..fae00f6 100644 --- a/apps/admin-web/src/views/AIPointUsageView.vue +++ b/apps/admin-web/src/views/AIPointUsageView.vue @@ -53,7 +53,7 @@ const columns = computed>(() => [ title: t('aiPoints.table.points'), dataIndex: 'points', key: 'points', - width: 110, + width: 188, align: 'right', }, { @@ -89,7 +89,7 @@ function handleTableChange(nextPage: number, nextPageSize: number): void { function getAIPointUsageTypeLabel(usageType: string): string { const key = `shell.aiUsageTypes.${usageType}` const label = t(key) - return label === key ? usageType : label + return label === key ? t('aiPoints.usageTypeFallback') : label } function getAIPointStatusMeta(status: string): { label: string; color: string } { @@ -108,8 +108,48 @@ function getAIPointStatusMeta(status: string): { label: string; color: string } } } -function formatAIPointDelta(item: AIPointUsageLog): string { - return item.status === 'refunded' ? `+${item.points}` : `-${item.points}` +function getAIPointStatusHelp(status: string): string | null { + switch (status) { + case 'pending': + return t('shell.aiUsagePendingHelp') + case 'refunded': + return t('aiPoints.refundedHelp') + default: + return null + } +} + +function getAIPointAmountMeta(item: AIPointUsageLog): { + primary: string + detail: string + tone: 'charge' | 'refund' | 'neutral' +} { + switch (item.status) { + case 'refunded': + return { + primary: t('aiPoints.amount.netZero'), + detail: t('aiPoints.amount.refundedFlow', { points: item.points }), + tone: 'refund', + } + case 'pending': + return { + primary: `-${item.points}`, + detail: t('aiPoints.amount.pendingFlow', { points: item.points }), + tone: 'charge', + } + case 'completed': + return { + primary: `-${item.points}`, + detail: t('aiPoints.amount.completedFlow', { points: item.points }), + tone: 'charge', + } + default: + return { + primary: t('aiPoints.amount.zero'), + detail: t('aiPoints.amount.failedFlow'), + tone: 'neutral', + } + } } @@ -208,12 +248,13 @@ function formatAIPointDelta(item: AIPointUsageLog): string { + + @@ -1010,6 +1167,45 @@ function backToBrands(): void { content: ':'; } +.tool-header-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; +} + +.tool-ai-fill { + background: linear-gradient(135deg, #1677ff, #722ed1) !important; + border: none !important; + color: #ffffff !important; + font-weight: 600; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(114, 46, 209, 0.25); + transition: all 0.3s ease; + padding: 0 16px; +} + +.tool-ai-fill:hover { + background: linear-gradient(135deg, #2563eb, #8b5cf6) !important; + box-shadow: 0 6px 16px rgba(114, 46, 209, 0.4); + transform: translateY(-1px); +} + +.tool-ai-fill[disabled], +.tool-ai-fill.ant-btn-loading { + background: #f1f5f9 !important; + border: 1px solid #e2e8f0 !important; + color: #94a3b8 !important; + box-shadow: none !important; + transform: none !important; +} + +.tool-ai-fill :deep(.anticon) { + font-size: 16px; +} + + + .tool-estimate { min-width: 168px; padding: 8px 12px; @@ -1199,19 +1395,24 @@ function backToBrands(): void { } .candidate-list { - display: flex; - flex-direction: column; - background: #ffffff; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 12px; + align-items: stretch; + padding: 12px; + background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; - overflow: hidden; } .candidate-empty { + grid-column: 1 / -1; padding: 32px; color: #94a3b8; text-align: center; - background: #f8fafc; + background: #ffffff; + border: 1px dashed #d8e0ec; + border-radius: 8px; } .candidate-row { @@ -1219,18 +1420,21 @@ function backToBrands(): void { grid-template-columns: 24px minmax(0, 1fr) auto auto; gap: 12px; align-items: center; + min-height: 58px; padding: 12px 16px; - background: transparent; - border-bottom: 1px solid #f1f5f9; - transition: background-color 0.2s ease; -} - -.candidate-row:last-child { - border-bottom: none; + background: #ffffff; + border: 1px solid #e6edf5; + border-radius: 8px; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease; } .candidate-row:hover { - background-color: #f8fafc; + background-color: #fbfdff; + border-color: #b7d6ff; + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.04); } .candidate-row__text { @@ -1254,15 +1458,6 @@ function backToBrands(): void { opacity: 1; } -.candidate-row--muted { - background: #f8fafc; - color: #94a3b8; -} - -.candidate-row--muted .candidate-row__text { - color: #94a3b8; -} - .wizard-page__footer { position: sticky; bottom: 0; @@ -1322,7 +1517,7 @@ function backToBrands(): void { } .candidate-row { - grid-template-columns: 24px minmax(0, 1fr) auto; + grid-template-columns: 24px minmax(0, 1fr) auto auto; } .wizard-page__footer { diff --git a/apps/admin-web/src/views/TemplateWizardView.vue b/apps/admin-web/src/views/TemplateWizardView.vue index a8f0bd1..c49d63b 100644 --- a/apps/admin-web/src/views/TemplateWizardView.vue +++ b/apps/admin-web/src/views/TemplateWizardView.vue @@ -10,6 +10,7 @@ import { import type { ArticleDetail, JsonValue, + Question, TemplateAnalyzeResult, TemplateAssistCompetitor, TemplateOutlineNode, @@ -20,6 +21,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router' +import AiWaitingModal from '@/components/AiWaitingModal.vue' import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue' import { articlesApi, brandsApi, normalizeInputParams, templatesApi } from '@/lib/api' import { getTemplateMeta } from '@/lib/display' @@ -170,7 +172,8 @@ const brandName = ref('') const officialWebsite = ref('') const brandSummary = ref('') -const keywordDrafts = ref([]) +const primaryQuestionId = ref(null) +const supplementalQuestionIds = ref([]) const competitorDrafts = ref([]) const assistTitles = ref([]) @@ -218,10 +221,10 @@ const draftArticleQuery = useQuery({ queryFn: () => articlesApi.detail(draftArticleId.value as number), }) -const keywordsQuery = useQuery({ - queryKey: computed(() => ['brands', selectedBrandId.value, 'keywords']), +const questionsQuery = useQuery({ + queryKey: computed(() => ['brands', selectedBrandId.value, 'questions', 'wizard']), enabled: computed(() => enabled.value && Boolean(selectedBrandId.value)), - queryFn: () => brandsApi.listKeywords(selectedBrandId.value as number), + queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number), }) const competitorsQuery = useQuery({ @@ -284,18 +287,41 @@ const selectedBrand = computed( () => brandsQuery.data.value?.find((brand) => brand.id === selectedBrandId.value) ?? null, ) -const keywordOptions = computed(() => - (keywordsQuery.data.value ?? []).map((item) => ({ - label: item.name, - value: item.name, +function findQuestionById(id: number | null): Question | null { + if (!id) { + return null + } + return questionsQuery.data.value?.find((item) => item.id === id) ?? null +} + +const questionOptions = computed(() => + (questionsQuery.data.value ?? []).map((item) => ({ + label: item.question_text, + value: item.id, })), ) +const supplementalQuestionOptions = computed(() => + questionOptions.value.filter((item) => item.value !== primaryQuestionId.value), +) +const primaryQuestion = computed(() => findQuestionById(primaryQuestionId.value)) +const supplementalQuestions = computed(() => + supplementalQuestionIds.value + .map((id) => findQuestionById(id)) + .filter((item): item is Question => Boolean(item)), +) +const primaryQuestionText = computed(() => primaryQuestion.value?.question_text.trim() || '') +const supplementalQuestionTexts = computed(() => + supplementalQuestions.value.map((item) => item.question_text.trim()).filter(Boolean), +) +const selectedQuestionTexts = computed(() => + dedupeStrings([primaryQuestionText.value, ...supplementalQuestionTexts.value]), +) const normalizedBrandName = computed( () => brandName.value.trim() || selectedBrand.value?.name || '', ) const finalTitle = computed(() => customTitle.value.trim() || selectedTitle.value.trim()) -const primaryKeyword = computed(() => keywordDrafts.value[0]?.trim() || '') +const primaryKeyword = computed(() => primaryQuestionText.value) const showCompetitorsCard = computed( () => !isResearchReportTemplate.value && @@ -333,7 +359,8 @@ const templateRenderContext = computed(() => brandName: normalizedBrandName.value, officialWebsite: officialWebsite.value, brandSummary: brandSummary.value, - primaryKeyword: primaryKeyword.value, + primaryQuestion: primaryQuestionText.value, + supplementalQuestions: supplementalQuestionTexts.value, inputParams: buildStructureInputParams(), competitorCount: buildCompetitorPayload().length, }), @@ -389,16 +416,16 @@ const generateStepCopy = computed( () => wizardConfig.value?.steps?.generate ?? {}, ) const brandCardCopy = computed( - () => wizardConfig.value?.basic?.cards?.brand ?? {}, + () => sanitizeLegacyKeywordCopy(wizardConfig.value?.basic?.cards?.brand ?? {}), ) -const keywordCardCopy = computed( - () => wizardConfig.value?.basic?.cards?.keywords ?? {}, +const questionCardCopy = computed( + () => sanitizeQuestionCardCopy(wizardConfig.value?.basic?.cards?.keywords ?? {}), ) const competitorsCardCopy = computed( () => wizardConfig.value?.basic?.cards?.competitors ?? {}, ) const titleCardCopy = computed( - () => wizardConfig.value?.structure?.cards?.choose_title ?? {}, + () => sanitizeLegacyKeywordCopy(wizardConfig.value?.structure?.cards?.choose_title ?? {}), ) const outlineCardCopy = computed( () => wizardConfig.value?.structure?.cards?.outline ?? {}, @@ -442,7 +469,8 @@ const hasWizardDraftContent = computed( Boolean(brandName.value.trim()) || Boolean(officialWebsite.value.trim()) || Boolean(brandSummary.value.trim()) || - keywordDrafts.value.some((item) => item.trim()) || + Boolean(primaryQuestionId.value) || + supplementalQuestionIds.value.length > 0 || competitorDrafts.value.some(hasCompetitorDraftContent) || assistTitles.value.length > 0 || Boolean(customTitle.value.trim()) || @@ -456,7 +484,7 @@ const assistStages = computed(() => assistTaskKind.value === 'analyze' ? [ t('templates.wizard.assist.analyzeStages.brand'), - t('templates.wizard.assist.analyzeStages.keywords'), + t('templates.wizard.assist.analyzeStages.questions'), showCompetitorsCard.value ? t('templates.wizard.assist.analyzeStages.competitors') : t('templates.wizard.assist.analyzeStages.context'), @@ -495,7 +523,8 @@ function resetWizardState(detail: NonNullable): voi brandName.value = '' officialWebsite.value = '' brandSummary.value = '' - keywordDrafts.value = [] + primaryQuestionId.value = null + supplementalQuestionIds.value = [] competitorDrafts.value = [] assistTitles.value = [] selectedTitle.value = '' @@ -515,7 +544,8 @@ function resetWizardState(detail: NonNullable): voi brandName: '', officialWebsite: '', brandSummary: '', - primaryKeyword: '', + primaryQuestion: '', + supplementalQuestions: [], inputParams: {}, competitorCount: 0, }), @@ -530,7 +560,8 @@ function resetWizardState(detail: NonNullable): voi brandName: '', officialWebsite: '', brandSummary: '', - primaryKeyword: '', + primaryQuestion: '', + supplementalQuestions: [], inputParams: {}, competitorCount: 0, }), @@ -553,7 +584,9 @@ function applyDraftArticleState(detail: ArticleDetail): void { brandName.value = stringFromUnknown(draftState.brand_name) || '' officialWebsite.value = stringFromUnknown(draftState.official_website) || '' brandSummary.value = stringFromUnknown(draftState.brand_summary) || '' - keywordDrafts.value = stringListFromUnknown(draftState.keyword_drafts) ?? [] + primaryQuestionId.value = numberFromUnknown(draftState.primary_question_id) ?? null + supplementalQuestionIds.value = + numberListFromUnknown(draftState.supplemental_question_ids)?.slice(0, 3) ?? [] competitorDrafts.value = parseDraftCompetitors(draftState.competitor_drafts) assistTitles.value = stringListFromUnknown(draftState.assist_titles) ?? [] selectedTitle.value = stringFromUnknown(draftState.selected_title) || '' @@ -619,6 +652,29 @@ watch( { immediate: true }, ) +watch(primaryQuestionId, (id) => { + if (!id) { + return + } + supplementalQuestionIds.value = supplementalQuestionIds.value.filter((item) => item !== id) +}) + +watch(supplementalQuestionIds, (ids) => { + const optionValues = supplementalQuestionOptions.value.map((item) => item.value) + const allowedIds = new Set(optionValues) + const shouldCheckOptions = optionValues.length > 0 + const normalized = ids.filter( + (id, index) => + id !== primaryQuestionId.value && + ids.indexOf(id) === index && + (!shouldCheckOptions || allowedIds.has(id)), + ) + const limited = normalized.slice(0, 3) + if (limited.length !== ids.length || limited.some((id, index) => id !== ids[index])) { + supplementalQuestionIds.value = limited + } +}) + watch(selectedBrandId, async (brandId) => { if (restoringDraft.value) { return @@ -643,9 +699,9 @@ watch(selectedBrandId, async (brandId) => { brandSummary.value = brand.description } - const keywordsResult = await keywordsQuery.refetch() - - keywordDrafts.value = dedupeStrings((keywordsResult.data ?? []).map((item) => item.name)) + primaryQuestionId.value = null + supplementalQuestionIds.value = [] + await questionsQuery.refetch() if (!showCompetitorsCard.value) { competitorDrafts.value = [] @@ -759,6 +815,14 @@ function validateBasicInfo(): boolean { message.warning(t('templates.wizard.messages.missingBrand')) return false } + if (!selectedBrandId.value) { + message.warning(t('templates.wizard.messages.selectBrandBeforeQuestion')) + return false + } + if (!primaryQuestionId.value || !primaryQuestionText.value) { + message.warning(t('templates.wizard.messages.missingPrimaryQuestion')) + return false + } return true } @@ -859,8 +923,8 @@ async function runAnalyzeTask(notifySuccess: boolean): Promise { brand_id: selectedBrandId.value, brand_name: normalizedBrandName.value, website: officialWebsite.value.trim() || null, + brand_question: primaryQuestionText.value, input_params: buildAssistInputParams(), - existing_keywords: keywordDrafts.value, existing_competitors: competitorPayload.length > 0 ? competitorPayload : undefined, }) @@ -893,8 +957,9 @@ async function runTitleTask(): Promise { brand_name: normalizedBrandName.value, website: officialWebsite.value.trim() || null, brand_summary: brandSummary.value.trim() || null, - input_params: buildAssistInputParams(), - keywords: keywordDrafts.value, + brand_question: primaryQuestionText.value, + input_params: buildTitleInputParams(), + keywords: primaryQuestionText.value ? [primaryQuestionText.value] : [], competitors: competitorPayload.length > 0 ? competitorPayload : undefined, }) @@ -928,8 +993,10 @@ async function runOutlineTask(): Promise { website: officialWebsite.value.trim() || null, brand_summary: brandSummary.value.trim() || null, title: finalTitle.value, + brand_question: primaryQuestionText.value, + supplemental_questions: supplementalQuestionTexts.value, input_params: buildStructureInputParams(), - keywords: keywordDrafts.value, + keywords: selectedQuestionTexts.value, competitors: competitorPayload.length > 0 ? competitorPayload : undefined, outline_sections: selectedOutlineLabels.value, }) @@ -1019,7 +1086,6 @@ function applyAnalyzeResult(result: TemplateAnalyzeResult): void { brandSummary.value = result.brand_summary.trim() } - keywordDrafts.value = dedupeStrings([...keywordDrafts.value, ...(result.keywords ?? [])]) if (showCompetitorsCard.value) { competitorDrafts.value = mergeCompetitors(competitorDrafts.value, result.competitors ?? []) } @@ -1097,8 +1163,42 @@ function buildCompetitorPayload(): TemplateAssistCompetitor[] { })) } +function sanitizeQuestionCardCopy(copy: TemplateCardCopy): TemplateCardCopy { + if (copy.title?.includes('关键词') || copy.hint?.includes('关键词')) { + return { + ...copy, + title: undefined, + hint: undefined, + } + } + return { + ...copy, + title: sanitizeLegacyKeywordText(copy.title) || undefined, + hint: sanitizeLegacyKeywordText(copy.hint) || undefined, + } +} + +function sanitizeLegacyKeywordCopy(copy: TemplateCardCopy): TemplateCardCopy { + return { + ...copy, + title: sanitizeLegacyKeywordText(copy.title) || undefined, + hint: sanitizeLegacyKeywordText(copy.hint) || undefined, + } +} + +function sanitizeLegacyKeywordText(value: string | undefined): string { + return (value || '') + .replace(/核心关键词/g, '品牌主问题') + .replace(/关键词/g, '品牌问题') + .replace(/标题生成/g, '后续生成') +} + function buildAssistInputParams(): Record { - const payload: Record = {} + const payload: Record = { + brand_question: primaryQuestionText.value, + primary_question: primaryQuestionText.value, + primary_keyword: primaryQuestionText.value, + } const derivedInputs = wizardConfig.value?.derived_inputs ?? {} for (const [key, config] of Object.entries(derivedInputs)) { const value = resolveDerivedInputValue(key, config) @@ -1110,8 +1210,16 @@ function buildAssistInputParams(): Record { return payload } +function buildTitleInputParams(): Record { + return buildAssistInputParams() +} + function buildStructureInputParams(): Record { const payload = buildAssistInputParams() + if (supplementalQuestionTexts.value.length > 0) { + payload.supplemental_questions = supplementalQuestionTexts.value + payload.keywords = selectedQuestionTexts.value + } if (reviewIntroHookPromptValue.value) { payload[reviewIntroHookFieldName.value] = reviewIntroHookPromptValue.value } @@ -1141,8 +1249,12 @@ function buildPayload(): Record { brand_name: normalizedBrandName.value || undefined, brand_summary: brandSummary.value.trim() || undefined, official_website: officialWebsite.value.trim() || undefined, - primary_keyword: primaryKeyword.value || undefined, - keywords: keywordDrafts.value.length > 0 ? dedupeStrings(keywordDrafts.value) : undefined, + brand_question: primaryQuestionText.value || undefined, + primary_question: primaryQuestionText.value || undefined, + supplemental_questions: + supplementalQuestionTexts.value.length > 0 ? supplementalQuestionTexts.value : undefined, + primary_keyword: primaryQuestionText.value || undefined, + keywords: selectedQuestionTexts.value.length > 0 ? selectedQuestionTexts.value : undefined, [reviewIntroHookFieldName.value]: reviewIntroHookPromptValue.value || undefined, knowledge_group_ids: selectedKnowledgeGroupIds.value.length > 0 ? selectedKnowledgeGroupIds.value : undefined, @@ -1173,7 +1285,10 @@ function buildDraftState(): Record { brand_name: brandName.value.trim(), official_website: officialWebsite.value.trim(), brand_summary: brandSummary.value.trim(), - keyword_drafts: dedupeStrings(keywordDrafts.value), + primary_question_id: primaryQuestionId.value, + primary_question_text: primaryQuestionText.value, + supplemental_question_ids: supplementalQuestionIds.value, + supplemental_question_texts: supplementalQuestionTexts.value, competitor_drafts: showCompetitorsCard.value ? competitorDrafts.value.map((item) => ({ library_id: item.libraryId ?? null, @@ -1945,17 +2060,23 @@ function buildTemplateRenderContext(input: { brandName: string officialWebsite: string brandSummary: string - primaryKeyword: string + primaryQuestion: string + supplementalQuestions: string[] inputParams: Record competitorCount: number }): Record { + const primaryQuestion = input.primaryQuestion.trim() + const supplementalQuestions = dedupeStrings(input.supplementalQuestions) const context: Record = { locale: input.locale.trim(), current_year: String(new Date().getFullYear()), brand_name: input.brandName.trim(), official_website: input.officialWebsite.trim(), brand_summary: input.brandSummary.trim(), - primary_keyword: input.primaryKeyword.trim() || input.brandName.trim(), + brand_question: primaryQuestion, + primary_question: primaryQuestion, + primary_keyword: primaryQuestion || input.brandName.trim(), + supplemental_questions: supplementalQuestions.join(', '), } for (const [key, value] of Object.entries(input.inputParams)) { @@ -2338,17 +2459,60 @@ function onStructureDragEnd(): void {
-

{{ keywordCardCopy.title || t('templates.wizard.sections.keywords') }}

-

{{ keywordCardCopy.hint || t('templates.wizard.hints.keywords') }}

+

+ {{ questionCardCopy.title || t('templates.wizard.sections.brandQuestions') }} +

+

+ {{ questionCardCopy.hint || t('templates.wizard.hints.brandQuestions') }} +

+
+
+
+
+ + +
+ +
+
+ + {{ supplementalQuestionIds.length }}/3 +
+ +

+ {{ t('templates.wizard.hints.supplementalQuestions') }} +

-
@@ -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)