feat(prompts): add prompts loader and configuration management

- Implemented a new prompts loader in `loader.go` to manage prompt templates and configurations.
- Introduced caching mechanism for prompt configurations to optimize loading.
- Added functions to apply platform-specific prompt template overrides.
- Created unit tests in `loader_test.go` to validate prompt configuration loading and reloading behavior.
- Ensured that the last valid configuration is retained in case of errors during reload.
This commit is contained in:
2026-04-13 16:08:12 +08:00
parent 6066f43a7d
commit 1b01caac0f
21 changed files with 1630 additions and 392 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ReloadOutlined } from "@ant-design/icons-vue";
import { MinusCircleFilled, ReloadOutlined } from "@ant-design/icons-vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message, notification } from "ant-design-vue";
import type {
@@ -86,7 +86,12 @@ watch(
}
selectionHydrated.value = false;
coverHydrated.value = false;
await refreshRuntime();
await Promise.allSettled([
refreshRuntime(),
props.articleId ? detailQuery.refetch() : Promise.resolve(),
accountsQuery.refetch(),
platformsQuery.refetch(),
]);
},
{ immediate: true },
);
@@ -96,7 +101,10 @@ watchEffect(() => {
return;
}
if (!coverHydrated.value && !detailQuery.isPending.value) {
if (!coverHydrated.value) {
if (detailQuery.isPending.value || detailQuery.isFetching.value) {
return;
}
const initialUrl = resolveApiURL(detailQuery.data.value?.cover_asset_url);
coverAssetUrl.value = initialUrl;
coverFileName.value = deriveCoverFileName(initialUrl);
@@ -110,8 +118,11 @@ watchEffect(() => {
if (
detailQuery.isPending.value ||
detailQuery.isFetching.value ||
accountsQuery.isPending.value ||
accountsQuery.isFetching.value ||
platformsQuery.isPending.value ||
platformsQuery.isFetching.value ||
runtimeLoading.value
) {
return;
@@ -475,35 +486,32 @@ function showPublishFailures(result: PublisherPublishResponse): void {
{{ coverRequired ? t("media.publish.messages.coverRequired") : t("media.publish.coverHint") }}
</p>
<div
v-if="effectiveCoverEnabled"
class="publish-modal__cover-body"
:class="{ 'publish-modal__cover-body--single': !coverAssetUrl }"
>
<button
type="button"
class="publish-modal__cover-preview"
@click="coverPickerOpen = true"
>
<template v-if="coverAssetUrl">
<img :src="coverAssetUrl" alt="cover preview" />
</template>
<template v-else>
<span class="publish-modal__cover-plus">+</span>
<span>{{ t("media.publish.coverUpload") }}</span>
</template>
</button>
<div v-if="effectiveCoverEnabled" class="publish-modal__cover-body">
<div class="publish-modal__cover-preview-wrap">
<button
type="button"
class="publish-modal__cover-preview"
@click="coverPickerOpen = true"
>
<template v-if="coverAssetUrl">
<img :src="coverAssetUrl" alt="cover preview" />
</template>
<template v-else>
<span class="publish-modal__cover-plus">+</span>
<span>{{ t("media.publish.coverUpload") }}</span>
</template>
</button>
<div v-if="coverAssetUrl" class="publish-modal__cover-side">
<div class="publish-modal__cover-actions">
<a-button @click="handleRemoveCover">
{{ t("article.editor.coverRemove") }}
</a-button>
</div>
<div class="publish-modal__cover-file">
{{ coverFileName || t("article.editor.coverSaved") }}
</div>
<button
v-if="coverAssetUrl"
type="button"
class="publish-modal__cover-remove"
:aria-label="t('article.editor.coverRemove')"
:title="t('article.editor.coverRemove')"
@click.stop="handleRemoveCover"
>
<MinusCircleFilled />
</button>
</div>
</div>
</section>
@@ -728,20 +736,21 @@ function showPublishFailures(result: PublisherPublishResponse): void {
}
.publish-modal__cover-body {
display: grid;
grid-template-columns: 176px minmax(0, 1fr);
gap: 16px;
display: flex;
margin-top: 14px;
}
.publish-modal__cover-body--single {
grid-template-columns: 176px;
.publish-modal__cover-preview-wrap {
position: relative;
width: 176px;
flex: 0 0 176px;
}
.publish-modal__cover-preview {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 124px;
padding: 0;
border: 1px dashed #d3dceb;
@@ -774,22 +783,29 @@ function showPublishFailures(result: PublisherPublishResponse): void {
line-height: 1;
}
.publish-modal__cover-side {
.publish-modal__cover-remove {
position: absolute;
top: 8px;
right: 8px;
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.14);
color: #ff4d4f;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease, color 0.2s ease;
}
.publish-modal__cover-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.publish-modal__cover-file {
color: #667085;
font-size: 13px;
line-height: 1.7;
.publish-modal__cover-remove:hover {
transform: scale(1.04);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.18);
color: #ff7875;
}
@media (max-width: 860px) {
@@ -802,7 +818,7 @@ function showPublishFailures(result: PublisherPublishResponse): void {
}
.publish-modal__cover-body {
grid-template-columns: 1fr;
display: flex;
}
}
</style>
+1
View File
@@ -558,6 +558,7 @@ export const monitoringApi = {
keyword_id?: number | null;
days?: number;
business_date?: string;
ai_platform_id?: string;
}) {
return apiClient.get<MonitoringDashboardCompositeResponse>("/api/tenant/monitoring/dashboard/composite", {
params,
@@ -142,6 +142,7 @@ const saveMutation = useMutation({
queryClient.invalidateQueries({ queryKey: ["workspace"] }),
queryClient.invalidateQueries({ queryKey: ["templates"] }),
queryClient.invalidateQueries({ queryKey: ["articles", "detail", articleId.value] }),
queryClient.invalidateQueries({ queryKey: ["articles", "detail", articleId.value, "publish-modal"] }),
]);
},
onError: (error) => {
@@ -29,6 +29,7 @@ const questionId = computed(() => parsePositiveNumber(route.params.questionId));
const questionHash = computed(() => normalizeQueryValue(route.query.question_hash));
const questionTitleFallback = computed(() => normalizeQueryValue(route.query.question_text));
const keywordId = computed(() => normalizeQueryValue(route.query.keyword_id));
const requestedPlatformId = computed(() => normalizeQueryValue(route.query.ai_platform_id));
const businessDate = computed(() => normalizeTrackingBusinessDate(route.query.business_date) || trackingToday);
const dateFrom = computed(() => {
return normalizeQueryValue(route.query.date_from) || businessDate.value;
@@ -44,6 +45,7 @@ const detailQuery = useQuery({
brandId.value,
questionId.value,
questionHash.value,
requestedPlatformId.value,
dateFrom.value,
dateTo.value,
]),
@@ -53,6 +55,7 @@ const detailQuery = useQuery({
date_from: dateFrom.value,
date_to: dateTo.value,
question_hash: questionHash.value || undefined,
ai_platform_id: requestedPlatformId.value || undefined,
}),
});
@@ -66,9 +69,8 @@ watch(
return;
}
const requestedPlatformId = normalizeQueryValue(route.query.ai_platform_id);
const requestedPlatform = requestedPlatformId
? platforms.find((item) => item.ai_platform_id === requestedPlatformId)
const requestedPlatform = requestedPlatformId.value
? platforms.find((item) => item.ai_platform_id === requestedPlatformId.value)
: null;
const sampledPlatform = platforms.find((item) => item.sample_status === "sampled");
const nextPlatform = requestedPlatform ?? sampledPlatform ?? platforms[0];
@@ -130,6 +132,7 @@ function goBack(): void {
query: {
brand_id: brandId.value ? String(brandId.value) : undefined,
keyword_id: keywordId.value || undefined,
ai_platform_id: requestedPlatformId.value || undefined,
business_date: businessDate.value,
},
});
+54 -2
View File
@@ -72,11 +72,26 @@ const platformAppearanceMap: Record<string, { color: string; surface: string; gl
kimi: { color: "#111827", surface: "#f3f4f6", glyph: "K" },
};
const trackingPlatformOptions = [
{ label: "全部", value: "all" },
{ label: "DeepSeek", value: "deepseek" },
{ label: "千问", value: "qwen" },
{ label: "豆包", value: "doubao" },
] as const;
const trackingPlatformIds = new Set<string>(
trackingPlatformOptions
.map((item) => item.value)
.filter((value) => value !== "all"),
);
const selectedBrandId = useStorage<number | null>("tracking_selected_brand", null);
const selectedKeywordId = useStorage<number | null>("tracking_selected_keyword", null);
const selectedPlatformId = useStorage<string>("tracking_selected_ai_platform_id", "all");
const selectedBusinessDate = useStorage<string>("tracking_selected_business_date", trackingToday);
const pluginPreparing = ref(false);
selectedPlatformId.value = normalizeTrackingPlatformId(selectedPlatformId.value);
selectedBusinessDate.value = normalizeTrackingBusinessDate(selectedBusinessDate.value) ?? trackingToday;
const brandsQuery = useQuery({
@@ -151,17 +166,28 @@ watch(
{ immediate: true },
);
watch([selectedBrandId, selectedKeywordId, selectedBusinessDate], ([brandId, keywordId, businessDate]) => {
watch(
() => route.query.ai_platform_id,
(value) => {
selectedPlatformId.value = normalizeTrackingPlatformId(value);
},
{ immediate: true },
);
watch([selectedBrandId, selectedKeywordId, selectedPlatformId, selectedBusinessDate], ([brandId, keywordId, platformId, businessDate]) => {
const nextBrandId = brandId ? String(brandId) : undefined;
const nextKeywordId = keywordId ? String(keywordId) : 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 currentPlatformId = normalizeTrackingPlatformId(route.query.ai_platform_id);
const currentBusinessDate = normalizeTrackingBusinessDate(route.query.business_date) ?? trackingToday;
if (
currentBrandId === nextBrandId &&
currentKeywordId === nextKeywordId &&
currentPlatformId === (nextPlatformId ?? "all") &&
currentBusinessDate === nextBusinessDate
) {
return;
@@ -172,6 +198,7 @@ watch([selectedBrandId, selectedKeywordId, selectedBusinessDate], ([brandId, key
query: {
...(nextBrandId ? { brand_id: nextBrandId } : {}),
...(nextKeywordId ? { keyword_id: nextKeywordId } : {}),
...(nextPlatformId ? { ai_platform_id: nextPlatformId } : {}),
business_date: nextBusinessDate,
},
});
@@ -208,13 +235,21 @@ async function ensureMonitoringPluginReady(options?: { silent?: boolean }): Prom
}
const dashboardQuery = useQuery({
queryKey: computed(() => ["tracking", "dashboard", selectedBrandId.value, selectedKeywordId.value, selectedBusinessDate.value]),
queryKey: computed(() => [
"tracking",
"dashboard",
selectedBrandId.value,
selectedKeywordId.value,
selectedPlatformId.value,
selectedBusinessDate.value,
]),
enabled: computed(() => Boolean(selectedBrandId.value)),
queryFn: () =>
monitoringApi.dashboardComposite({
brand_id: selectedBrandId.value ?? undefined,
keyword_id: selectedKeywordId.value,
days: trackingMaxHistoryDays,
ai_platform_id: selectedPlatformId.value !== "all" ? selectedPlatformId.value : undefined,
business_date: selectedBusinessDate.value,
}),
});
@@ -451,6 +486,7 @@ function openQuestion(question: MonitoringHotQuestion): void {
business_date: selectedBusinessDate.value,
date_from: selectedBusinessDate.value,
date_to: selectedBusinessDate.value,
...(selectedPlatformId.value !== "all" ? { ai_platform_id: selectedPlatformId.value } : {}),
...(selectedKeywordId.value ? { keyword_id: String(selectedKeywordId.value) } : {}),
},
});
@@ -554,6 +590,14 @@ function normalizeQueryValue(value: unknown): string {
return String(Array.isArray(value) ? value[0] ?? "" : value ?? "").trim();
}
function normalizeTrackingPlatformId(value: unknown): string {
const normalized = normalizeQueryValue(value).toLowerCase();
if (!normalized) {
return "all";
}
return trackingPlatformIds.has(normalized) ? normalized : "all";
}
function normalizeTrackingBusinessDate(value: unknown): string | null {
const normalized = normalizeQueryValue(value);
if (!normalized) {
@@ -588,6 +632,14 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
>
<template #actions>
<a-space wrap align="center" class="tracking-actions-wrap">
<div class="tracking-action-item">
<span class="tracking-action-label">平台</span>
<a-select
v-model:value="selectedPlatformId"
class="tracking-select"
:options="trackingPlatformOptions"
/>
</div>
<div class="tracking-action-item">
<span class="tracking-action-label">品牌</span>
<a-select
@@ -199,7 +199,7 @@ async function publishToutiaohao(context: AdapterContext): Promise<PlatformPubli
trends_writing_tag: "0",
claim_exclusive: "0",
info_source: JSON.stringify({
source_type: 3,
source_type: 5,
source_author_uid: "",
time_format: "",
position: {},
+2 -2
View File
@@ -324,8 +324,8 @@ async function publishDraft(draftId: string): Promise<boolean> {
comment_permission: "anyone",
},
creationStatement: {
disclaimer_type: "ai_creation",
disclaimer_status: "open",
disclaimer_type: "",
disclaimer_status: "",
},
contentsTables: {
table_of_contents_enabled: false,
+2
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
@@ -94,6 +95,7 @@ func main() {
if err != nil {
log.Fatalf("load config: %v", err)
}
prompts.SetConfigFile(filepath.Join(filepath.Dir(configPath), "prompts.yml"))
ctx := context.Background()
businessPool, err := pgxpool.New(ctx, cfg.Database.DSN())
+2 -2
View File
@@ -74,8 +74,8 @@ llm:
provider: ark
base_url: https://ark.cn-beijing.volces.com/api/v3
api_key: "7fb6c66b-129c-4935-9617-709236c4205a"
model: doubao-seed-2-0-mini-260215 #doubao-seed-2-0-lite-260215
knowledge_url_model: doubao-seed-2-0-mini-260215
model: doubao-seed-2-0-lite-260215 #
knowledge_url_model: doubao-seed-2-0-mini-260215 #doubao-seed-2-0-mini-260215
timeout: 2m
max_output_tokens: 16000
temperature: 0.7
+538
View File
@@ -0,0 +1,538 @@
runtime:
default_generation_base_prompt_template: '你是一名专业内容编辑,请基于已提供的信息,为模板「%s」写出一篇完整的 Markdown 文章。'
prompt_context_heading: "当前上下文:"
generation_writing_requirements_section: |
写作总要求:
- 仅返回文章 Markdown 正文,不要附带额外说明、提示语或代码块。
- 输出语言与 locale 一致:zh-CN 使用简体中文,en-US 使用自然、专业的英语。
- 如提供了 title,使用该标题作为文章主标题,并围绕它展开,不要另起一个无关标题。
- 如提供了 article_outline,一级节点是正文的最终小节标题,必须按顺序展开;二级及更深节点仅作为该小节的行文思路、论证顺序或信息要点,不要机械写成额外标题。
- 除主标题和一级小节标题外,默认不要把大纲子节点直接写成 Markdown 标题;子节点内容应自然融入段落、列表或过渡句中。
- 如提供了 key_points,正文中必须覆盖这些重点,不要遗漏。
- 每个核心段落都要有明确判断、原因解释、适用场景或对比维度,避免空话、套话和重复表述。
- 信息不足时,不要编造具体事实、价格、数据、测试结果、用户评价或机构结论;可以用稳妥表述说明判断边界。
- 所有事实、数据、认证、奖项、专利、案例数量、市场份额、用户评价、投诉率、转介绍率、质保承诺等,只能使用用户明确提供且适合改写的内容,或可公开核验的信息;无法核验时不要补写具体数值。
- 保持客观、克制、非广告化,不使用夸张宣传语、绝对化表述或暗示性背书,如“顶级”“第一”“首选”“最靠谱”“权威认证”“唯一”“零投诉”等。
- 如涉及品牌、公司、机构或服务商比较,默认按信息整理顺序展开,不构成排名、评奖或唯一推荐;不要刻意贬低同行。
- 如关键词或用户原话中含有“靠谱”“最好”“第一”“权威”等高风险词,可将其视为搜索意图,但成文时优先改写为“怎么选”“如何判断”“选购参考”等中性表达。
- 不输出联系方式、购买引导、广告标识、治疗/收益保证或其他容易触发平台审核风险的表述。
- 使用清晰的小节标题和短段落;仅在确实有助于理解时使用列表。
- 结尾应给出清晰结论、适合对象、不适合对象、信息边界或下一步建议,帮助读者完成判断。
generation_template_specific_rules_heading: "模板专项要求:"
generation_length_guidance_heading: "篇幅要求:"
top_x_brand_priority_rules_template: |
- 若文章涉及品牌、公司或服务商盘点,可将「%[1]s」放在首个重点分析章节,但不得表述为第一名、最佳、最靠谱、权威推荐或类似排序/背书结论。
- 「%[1]s」对应章节可以写得更充分,优先展开其已提供且适合改写的服务范围、案例类型、交付方式、材料/工艺、适合人群、售后机制与信息边界。
- 其他对象保持客观介绍,重点写各自特点、适用场景和公开可见信息;信息不足时可说明“公开信息有限”,不要补写具体数据。
- 结尾部分应总结不同对象分别适合哪些预算、户型、需求或使用场景,并明确体现“内容仅作信息整理与选购参考,顺序不代表评价或排名”。
- 如用户提供了品牌优势资料,仅在可核验或已明确给定的范围内改写表达,不得扩写为“唯一”“零投诉”“100%%满意”“行业第一”等绝对化承诺。
- 全文避免使用“排行榜、排行、榜、Top、首选、最靠谱、权威、最好、背书”等易引发广告法或平台审核风险的词。
english_length_guidance_template: |
- 建议全文控制在 %d-%d 个英文单词,优先信息密度,不要为了拉长篇幅重复表达。
- 引言和结论保持简洁,大多数一级章节写 1-3 个紧凑段落即可;只有最重要的章节需要展开。
- 一段能说清的内容不要拆成多段重复表达。
chinese_length_guidance_template: |
- 建议全文控制在 %d-%d 字左右,优先信息密度,不要为了凑字数重复表达。
- 引言和结论保持简洁,大多数一级章节写 1-3 个自然段即可;只有最关键的章节需要更充分展开。
- 能用一段说清的内容,不要拆成多段同义反复;主体部分重点写判断、原因、场景和建议。
context_labels:
locale: "语言"
title: "标题"
topic: "主题"
product_name: "产品名"
subject: "研究主题"
brand_name: "品牌名"
brand: "品牌"
official_website: "官网"
website: "官网"
primary_keyword: "核心关键词"
keywords: "关键词"
existing_keywords: "关键词"
competitors: "竞品"
existing_competitors: "竞品"
competitor_names: "竞品名称"
competitor_count: "竞品数量"
brand_summary: "品牌摘要"
category: "品类"
count: "数量"
top_count: "推荐数量"
keyword_count: "关键词数量"
depth: "深度"
article_outline: "文章大纲"
outline_sections: "已选段落"
key_points: "关键要点"
review_intro_hook: "评测引言钩子"
template_key: "模板标识"
template_name: "模板名称"
current_year: "当前年份"
input_params: "输入参数"
template_context_with_json_example_template: |
%s
模板上下文:
%s
JSON 输出示例:
%s
json_output_example_heading: "JSON 输出示例:"
analyze_output_example: |
{
"brand_summary": "一句简洁的品牌/主题描述",
"keywords": ["关键词1", "关键词2", "关键词3", "关键词4", "关键词5"],
"competitors": [
{
"name": "竞品名",
"website": "https://example.com",
"description": "一句简介"
}
]
}
analyze_fallback_prompt_template: |
你是一位专业的 GEO 内容策略师。
任务:
1. 分析品牌/主题上下文。
2. 推荐最相关的 GEO 文章关键词。
3. 推荐可信的竞品网站或竞争品牌,用于对比或引用。
规则:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
- 关键词应为简洁、中性的搜索短语,避免“第一”“最好”“最靠谱”“权威”“Top”等排序或背书词。
- 竞品应去重且真实。不确定时 website 字段可留空,但不要编造虚假链接。
- 若涉及品牌优势,只概括用户明确提供或可公开核验的信息,不放大为承诺式表述。
- brand_summary 限 1-2 句。
- 最多返回 6 个竞品和 5 个关键词。
模板上下文:
%s
JSON 输出示例:
%s
title_custom_output_requirements_section: |
输出要求:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 返回恰好 5 个字符串的 JSON 数组。
- 尊重当前关键词列表和竞品上下文,因为用户可能已手动编辑。
- 标题避免使用“排行”“榜”“Top”“靠谱”“首选”“权威”“最好”“背书”“第一”等词,不写绝对化或承诺式表述。
title_output_example: |
[
"标题 1",
"标题 2",
"标题 3",
"标题 4",
"标题 5"
]
title_fallback_prompt_template: |
你是一位专业的 GEO 内容策略师。
任务:
为当前内容摘要生成 5 个优质文章标题候选。
规则:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 返回 5 个字符串的 JSON 数组,不要返回对象。
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
- 标题应实用、具体,并契合当前模板的内容方向。
- 尊重当前关键词列表和竞品上下文,因为用户可能已手动编辑。
- 中文标题尽量控制在 30 字以内,可优先采用疑问句或总结式表达。
- 避免泛泛的广告文案、空洞口号、排序词、背书词和绝对化承诺,如“第一”“首选”“最靠谱”“权威”“Top”“榜”等。
模板上下文:
%s
JSON 输出示例:
%s
outline_output_example: |
{
"outline": [
{
"outline": "网站列表",
"children": [
{ "outline": "竞品 A" },
{ "outline": "竞品 B" }
]
},
{
"outline": "文章关键要点",
"children": [
{ "outline": "要点 1" },
{ "outline": "要点 2" }
]
}
]
}
outline_custom_output_requirements_section: |
输出要求:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 返回 JSON 对象,格式固定为 {"outline":[...]}
- outline 字段是顶层大纲节点数组,每个节点格式为 {"outline":"...","children":[...]}
- 每个已选大纲段落应作为顶层节点出现,语言与请求保持一致。
- 尊重当前关键词、竞品和关键要点,因为用户可能已手动编辑。
- 若涉及多品牌或多对象比较,按信息整理顺序组织,不要把大纲写成排名、榜单或评奖结构。
- 在合适位置体现选择依据、适合场景、信息边界和总结建议。
- 不要返回额外字段,不要返回纯数组,不要返回说明文字。
outline_fallback_prompt_template: |
你是一位专业的 GEO 内容策略师。
任务:
为当前内容摘要生成一份实用的文章大纲。
规则:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 返回 JSON 对象,格式固定为 {"outline":[...]}。
- outline 字段中的每个顶层节点 "outline" 值必须保持已选段落标签原文。
- 在合适的位置为每个顶层段落添加简洁的子大纲条目。
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
- 尊重当前标题、关键词、竞品、关键要点和品牌上下文,因为用户可能已手动编辑。
- 若涉及多品牌或多对象比较,按信息整理顺序展开,不构成排名或评奖。
- 大纲应具体、不含推广语气,适合后续完整成文。
- 不要返回额外字段,不要返回纯数组,不要返回说明文字。
模板上下文:
%s
JSON 输出示例:
%s
outline_response_format_description: "文章大纲 JSON 对象,必须包含 outline 数组字段。"
prompt_rule_task_name_supplement_format: "任务名称:%s"
prompt_rule_scene_supplement_format: "适用场景:%s"
prompt_rule_tone_supplement_format: "默认语气:%s"
prompt_rule_word_count_supplement_format: "建议字数:%d 字左右"
prompt_rule_target_platform_supplement_format: "目标发布平台:%s"
prompt_rule_supplement_heading: "补充要求:"
prompt_rule_output_requirements_section: |
输出要求:
1. 直接输出完整可发布的中文 Markdown 文章。
2. 标题使用一级标题,并由 AI 根据正文内容自行生成,不要直接复用任务名称。
3. 不要输出你的思考过程、解释或致歉。
4. 保持客观中立,避免排名、绝对化承诺、权威背书、夸大宣传和刻意贬损同行。
knowledge_prompt_intro_lines:
- "以下为可参考的知识库内容,只用于补充事实背景、术语定义、产品信息或表达素材:"
- "- 优先吸收并改写,不要逐段照抄。"
- "- 若知识库内容与用户明确输入冲突,以用户输入为准。"
- "- 信息不足时不要虚构事实。"
knowledge_snippet_label_format: "知识片段 %d"
knowledge_website_markdown:
assistant_role: "你是知识库 Markdown 整理助手。"
task: "任务:把给定网页正文整理为适合知识库只读预览的 Markdown。"
requirements:
- "1. 只根据原文整理,不要补充事实,不要总结扩写。"
- "2. 保留标题、列表、表格、编号、联系方式等关键信息。"
- "3. 如果原文明显是表格,请输出 Markdown 表格。"
- "4. 不要输出解释,不要输出代码块包裹,只输出 Markdown 正文。"
title_prefix: "网页标题:"
url_prefix: "网页地址:"
scope_single: "以下是网页正文。"
scope_multi_format: "以下是网页正文的第 %d/%d 段。"
platform_templates:
top_x_article:
prompt_template: |
你是一名经验丰富的行业观察型内容编辑,请围绕「{{topic}}」撰写一篇完整的 Markdown 多维度盘点文章。
文章目标:
1. 帮读者快速理解这个主题下值得关注的 {{count}} 个方向、产品或对象。
2. 基于已提供资料与可公开核验的信息,说明各对象的特点、适用场景、服务范围或选购价值。
3. 在保持客观中立的前提下,让读者形成清晰、可执行的筛选思路。
写作要求:
- 文章总结构使用“开篇概述 -> 分段论述 -> 总结建议”,正文各主体段落使用 H2 小标题。
- 如提供了 article_outline,一级节点是正文的最终小节标题,必须按顺序展开;二级及更深节点仅作为该小节的行文思路、论证顺序或信息要点,不要机械写成额外标题。
- 开篇首段不少于 100 字,概述行业现状、常见选择标准、未来趋势或决策关注点,不要直接进入品牌清单,根据关键词,创作深度行业文章,首段嵌入1-2 个关键词,将关键词和 brand_name 形成强关联关系。
- 如果提供了 competitors、品牌或关键词上下文,要自然融入横向比较,但不要机械重复名单。
- 如提供 brand_name,可将其放在首个重点分析章节,写得更充分,但不得写成“第一名”“首选”“最靠谱”“权威推荐”或类似排序/背书结论。
- 第一位置重点写,重点突出 brand_name 的优势,优势内容符合Google网页评估的EEAT原则(专业性Expertise、经验Experience、权威性Authoritativeness、可信度Trustworthiness),但不允许出现任何EEAT等相关文字,排名均列成H2小标题呈现。
- 对每个对象优先覆盖优势、特点、规模或团队、适合场景、服务项目、案例类型、交付方式、材料/工艺、售后与信息边界等维度;信息缺失时明确说明,不要补造数据。
- 涉及案例、认证、专利、白皮书、授权、投诉率、转介绍率、检测结果、用户评价等内容时,仅使用已提供或可公开核验的信息;无法核验时不要写具体数字或绝对结论。
- 不要写成“排行榜”“推荐榜”或评奖稿;全文应明确体现“内容按信息整理顺序呈现,不构成排名,顺序不代表优劣”。
- 正文可适度使用“问题 - 答案”、项目符号和编号步骤增强可读性,但不要堆砌格式。
- 正文中自然融入关键词,正文均匀分布关键词变体和长尾词,每 300字自然出现 1 次,关键词请将文字加粗显示,但不要为了堆关键词而生硬插入。
- 全文禁止出现贬低同行、禁止出现虚假夸大,排名位置明确写出“排名不分先后,仅供参考”。
- 正文中禁止出现联系方式、禁止出现广告标识等推广性用语,禁止出现低俗和违法违规内容。
- 全文禁止出现的词汇有:“头部”、“首选”、“TOP”、“排行”“榜”“靠谱”“权威”以及其他违法广告法等词汇内容。
- 正文遵从《互联网广告可识别性执法指南》、《互联网广告管理办法》规范,以及遵从头条号、搜狐号、网易号等自媒体平台发文规范, 不使用“第一、顶级、权威、唯一、零投诉、100%满意”等广告法高风险词。
- 正文采用第三方检测数据、行业认证、专利和白皮书等立体证明,杜绝一切夸大宣传、虚假认识、虚假数据等信息。
- 结尾总结不同对象分别适合哪些需求,并给出理性选择建议。
- 全文总字数少于1800字。
analyze_prompt_template: |
你是一位专业的 GEO 内容策略师,正在为推荐类文章做品牌与竞品分析。
模板: {{template_name}}
语言: {{locale}}
品牌名: {{brand_name}}
官网: {{official_website}}
任务:
1. 联网分析品牌/话题上下文,给出 1-2 句中性、克制的品牌主营业务摘要。
2. 整理最多 5 个适合推荐类文章或选购参考内容的搜索关键词。
3. 整理最多 6 个可信、可公开核验的竞品或对比对象。
规则:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 使用与 locale 一致的语言。
- 关键词应为简洁、中性的搜索短语,避免“第一”“最好”“靠谱”“权威”“Top”等排序或背书词,关键词如用户原词包含这类表达,可改写为“怎么选”“如何判断”“选购参考”。
- 关键词不包含 brand_name 或 competitor_names,但要与它们形成明显关联,且优先覆盖用户关注的核心痛点和搜索意图。
- 关键词是行业痛点词和用户高频搜索词汇。
- 竞品应去重且真实,不确定时 website 留空,不要编造 URL。
- 若品牌优势信息带有强承诺或绝对化表述,输出时改写为中性概括,不保留无法核验的极限词。
title_prompt_template: |
你正在为一篇推荐类文章生成 5 个候选标题。
模板: {{template_name}}
语言: {{locale}}
当前年份: {{current_year}}
核心关键词: {{primary_keyword}}
品牌名: {{brand_name}}
推荐数量: {{top_count}}
竞品名称: {{competitor_names}}
要求:
- 标题风格应契合实用选购指南、盘点观察、横向比较和决策参考等内容调性。
- 标题不出现 brand_name、competitor_names 等具体品牌或竞品名称。
- 优先体现当前年份、地域或主题线索、数量信息和核心关键词,但保持自然,不为凑字段硬拼。
- 标题尽量使用疑问句或总结式表达,中文标题尽量控制在 30 字以内。
- 撰写一个疑问语气的汇总类的标题,但标题中不能缺少[年份][地域][数字][关键词][行业][总结]等字段.
- 标题里一定要出现行业痛点词和客户高频搜索词。
- 标题中禁止出现的词汇有:“头部”、“首选”、“TOP”、“排行”“榜”“靠谱”“权威”“有限””背书”“医院排名”等词汇内容.
- 不要机械重复模板名称或「Top X 文章」字样。
- 每个标题应具体、可读,且角度各不相同,不写标题党。
outline_prompt_template: |
你正在为一篇推荐类文章生成实用大纲。
模板: {{template_name}}
语言: {{locale}}
标题: {{title}}
核心关键词: {{primary_keyword}}
品牌名: {{brand_name}}
竞品名称: {{competitor_names}}
已选段落: {{outline_sections}}
关键要点: {{key_points}}
要求:
- 每个已选段落作为顶层节点,下设具体子条目。
- 首段需体现行业现状、选择依据或趋势观察,方便后续生成不少于 100 字的开篇概述。
- 多品牌部分按品牌名或观察样本逐一展开,不要写成排名、榜单或评奖结构。
- 在合适位置体现优势、特点、规模或团队、适合场景、服务项目、案例类型、信息边界和总结建议。
- 如有 brand_name,可放在首个重点分析章节,但不要出现“第一名”或类似排序表述。
- 内容应具体、有对比、有结论导向,避免泛泛而谈和推广腔。
- 使用与 locale 一致的语言。
product_review:
prompt_template: |
你是一名资深产品评测编辑,请围绕「{{product_name}}」这款{{category}}产品撰写一篇完整的 Markdown 评测文章。
文章目标:
1. 帮读者判断这款产品是否值得关注或购买。
2. 解释它的核心能力、实际使用感受、主要优缺点和适合人群。
写作要求:
- 文章结构使用“开篇概述 -> 功能与体验拆解 -> 适合场景 -> 总结建议”,正文主体采用 H2 小标题。
- 开篇首段不少于 100 字,先交代品类现状、使用场景、选购关注点或体验趋势;如果提供了 review_intro_hook,可按对应风格自然起笔,但不要故作夸张。
- 正文要覆盖产品定位、核心功能、使用体验、限制点、适用场景和购买建议。
- 评价必须有判断,不要只罗列卖点;要说明为什么是优点、在什么情况下会变成限制。
- 仅使用已提供或可公开核验的功能、参数、认证、测试、案例和用户反馈;不要编造跑分、销量、口碑、奖项、专利或第三方结论。
- 可使用“问题 - 答案”、项目符号和编号步骤增强可读性,但不要堆砌关键词或广告化表达。
- 不输出联系方式、购买引导、广告标识,不使用“顶级”“完美”“无敌”“零差评”“行业第一”等绝对化词。
- 结论写清楚适合谁、不适合谁,以及在什么条件下更值得考虑。
- 全文保持客观、务实,不要写成品牌宣传稿或保证效果的承诺文案。
analyze_prompt_template: |
你是一位专业的 GEO 内容策略师,正在为产品评测文章做品牌与产品分析。
模板: {{template_name}}
语言: {{locale}}
品牌名: {{brand_name}}
官网: {{official_website}}
产品名: {{product_name}}
品类: {{category}}
任务:
1. 分析产品与品牌上下文,给出 1-2 句品牌摘要。
2. 推荐最多 5 个适合评测文章的搜索关键词,聚焦产品能力、使用场景和购买决策。
3. 推荐最多 6 个可信的同类产品或替代方案。
规则:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 使用与 locale 一致的语言。
- 关键词侧重评测视角,如「XX 评测」「XX 怎么选」「XX 使用感受」「XX 适合谁」。
- 竞品应去重且真实,不确定时 website 留空。
- 避免输出“封神”“必买”“最强”“零差评”“第一”等夸张或绝对化词。
title_prompt_template: |
你正在为一篇产品评测文章生成 5 个候选标题。
模板: {{template_name}}
语言: {{locale}}
当前年份: {{current_year}}
产品名: {{product_name}}
品类: {{category}}
品牌名: {{brand_name}}
核心关键词: {{primary_keyword}}
要求:
- 标题应像可信的评测、测评、购买建议类内容。
- 用具体表达代替空洞口号。
- 当 locale 为 zh-CN 时,标题应贴合中文内容平台风格,可使用评测、实测、怎么选、使用观察、适不适合等表达。
- 标题尽量使用疑问句或总结式表达,中文标题尽量控制在 30 字以内。
- 禁止使用“第一”“首选”“最好”“权威”“Top”“排行”“榜”“神器”“封神”“零差评”等词。
- 返回 5 个角度明确不同的标题。
outline_prompt_template: |
你正在为一篇产品评测文章生成实用大纲。
模板: {{template_name}}
语言: {{locale}}
当前年份: {{current_year}}
标题: {{title}}
产品名: {{product_name}}
品类: {{category}}
品牌名: {{brand_name}}
核心关键词: {{primary_keyword}}
评测引言钩子: {{review_intro_hook}}
已选段落: {{outline_sections}}
关键要点: {{key_points}}
要求:
- 每个已选段落作为顶层节点,使用与请求一致的语言。
- 首段需能承接不少于 100 字的品类背景或使用场景概述。
- 当有引言段落时,子条目应体现所选的评测引言钩子风格。
- 聚焦具体能力拆解、真实使用判断、优缺点权衡、适合人群和信息边界。
- 结尾部分要预留总结建议,不写保证效果、绝对化承诺或促销导向。
- 大纲务实、简洁、不含推广语气。
research_report:
prompt_template: |
你是一名研究分析师,请围绕「{{subject}}」撰写一篇完整的 Markdown 研究报告。
文章目标:
1. 提炼关键事实、趋势与结构化发现。
2. 解释背后原因,并给出可执行的判断或建议。
写作要求:
- 文章结构使用“开篇概述 -> 核心发现 -> 原因分析 -> 风险与影响 -> 总结建议”,正文主体采用 H2 小标题。
- 开篇首段不少于 100 字,先概述研究背景、观察范围、行业现状和阅读收益。
- 重点突出 brand_name 的优势,优势内容符合Google网页评估的EEAT原则(专业性Expertise、经验Experience、权威性Authoritativeness、可信度Trustworthiness),但不允许出现任何EEAT等相关文字,排名均列成H2小标题呈现。
- 当 depth = overview 时,保持结构清晰、重点集中;当 depth = detailed 时,增加分析层次、因果解释和建议细节。
- 不要泛泛复述背景,要突出关键发现、变化趋势、风险与影响。
- 如果提供了品牌、关键词或参考对象,应把它们作为分析参照,而不是简单罗列。
- 数据、案例、结论、认证、白皮书、政策、行业报告引用等,只能使用已提供或可公开核验的信息;不编造来源,不夸大确定性。
- 明确区分事实、分析判断与合理推断;无法核验时要提示信息边界,不把推断写成定论。
- 可适度使用问题 - 答案、项目符号和编号步骤增强可读性,但不要写成营销软文。
- 不输出联系方式、行动号召或广告标识,不使用“权威结论”“唯一答案”“行业第一”等夸张表述。
- 摘要部分先给核心结论,正文再展开依据,结尾落到行动建议或后续观察点。
analyze_prompt_template: |
你是一位专业的 GEO 内容策略师,正在为研究报告做主题与背景分析。
模板: {{template_name}}
语言: {{locale}}
研究主题: {{subject}}
深度: {{depth}}
品牌名: {{brand_name}}
官网: {{official_website}}
任务:
1. 分析研究主题上下文,给出 1-2 句主题摘要。
2. 推荐最多 5 个适合研究报告的搜索关键词,侧重行业术语和趋势表达。
3. 推荐最多 6 个可作为参照的品牌、机构或竞争对手。
规则:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 使用与 locale 一致的语言。
- 关键词应体现分析性视角,如「XX 市场分析」「XX 趋势」「XX 行业观察」「XX 研究总结」。
- 竞品应去重且真实,不确定时 website 留空。
- 避免“最权威”“唯一结论”“第一”“Top”等夸张或背书词。
title_prompt_template: |
你正在为一篇研究报告文章生成 5 个候选标题。
模板: {{template_name}}
语言: {{locale}}
当前年份: {{current_year}}
研究主题: {{subject}}
深度: {{depth}}
品牌名: {{brand_name}}
核心关键词: {{primary_keyword}}
要求:
- 标题应具有分析性、聚焦感和洞察力。
- 避免模糊的企业公关式措辞。
- 当 locale 为 zh-CN 时,可使用研究报告、趋势洞察、观察、判断、分析、总结等表达。
- 标题尽量使用总结式或提问式表达,中文标题尽量控制在 30 字以内。
- 禁止使用“权威”“独家”“第一”“Top”“排行”“榜”“最强”“首选”等词。
- 返回 5 个角度各异但可信的报告风格标题。
outline_prompt_template: |
你正在为一篇研究报告文章生成实用大纲。
模板: {{template_name}}
语言: {{locale}}
标题: {{title}}
研究主题: {{subject}}
深度: {{depth}}
品牌名: {{brand_name}}
核心关键词: {{primary_keyword}}
已选段落: {{outline_sections}}
关键要点: {{key_points}}
要求:
- 每个已选段落作为顶层节点,下设具体子条目。
- 首段需能承接不少于 100 字的研究背景、行业现状或观察范围概述。
- 关键发现段落应按数据驱动的方式组织子条目。
- 在合适位置安排研究方法、信息边界、风险影响和行动建议。
- 内容应具有结构性、分析深度和可操作性,不写宣传口径。
- 使用与 locale 一致的语言。
brand_search_expansion:
prompt_template: |
你是一名擅长搜索意图写作的内容编辑,请围绕「{{brand}} {{keyword}}」撰写一篇完整的 Markdown 品牌词搜索扩写文章。
文章目标:
1. 直接回答搜索用户最关心的问题。
2. 解释品牌背景、核心信息和常见对比/疑问,承接后续转化或继续了解。
写作要求:
- 文章结构使用“开篇概述 -> 搜索意图拆解 -> 品牌信息说明 -> 常见问题/对比观察 -> 总结建议”,正文主体采用 H2 小标题。
- 重点突出 brand_name 的优势,优势内容符合Google网页评估的EEAT原则(专业性Expertise、经验Experience、权威性Authoritativeness、可信度Trustworthiness),但不允许出现任何EEAT等相关文字,排名均列成H2小标题呈现。
- 开篇首段不少于 100 字,先解释用户为什么会搜索这个词、当前常见顾虑以及判断思路,不要直接写成品牌宣传开场。
- 正文应覆盖品牌是什么、用户为什么会搜这个词、常见疑问、对比点和下一步决策信息。
- 如果提供了竞品或替代方案,按用户常见比较维度展开,不要空泛地说“各有优势”。
- 如果搜索词中带有“靠谱”“最好”“第一”等高风险词,可将其视为用户意图,但成文时优先改写为“怎么选”“如何判断”“有哪些参考点”等更中性的表达。
- 涉及品牌历史、案例、授权、检测、投诉率、服务承诺、用户评价等内容时,仅使用已提供或可公开核验的信息;不能编造或放大。
- 正文可适度使用问题 - 答案、项目符号和编号步骤增强可读性,但不要机械堆砌关键词或过度加粗。
- 语气客观、有信息量,避免企业宣传腔、联系方式、行动号召和广告标识。
- 结尾给出简洁总结,并提示用户接下来应该关注什么信息来继续判断。
analyze_prompt_template: |
你是一位专业的 GEO 内容策略师,正在为品牌词搜索扩写文章做品牌与意图分析。
模板: {{template_name}}
语言: {{locale}}
品牌名: {{brand_name}}
官网: {{official_website}}
关键词: {{primary_keyword}}
任务:
1. 分析品牌与搜索意图上下文,给出 1-2 句品牌摘要。
2. 推荐最多 5 个品牌词搜索相关的关键词,侧重搜索意图和用户疑问。
3. 推荐最多 6 个可信的竞品或替代方案。
规则:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 使用与 locale 一致的语言。
- 关键词应贴合搜索意图,如「XX 是什么」「XX 怎么选」「XX 对比 YY」「XX 适合谁」。
- 竞品应去重且真实,不确定时 website 留空。
- 避免输出“最靠谱”“第一”“权威”“Top”“首选”等排序或背书词。
title_prompt_template: |
你正在为一篇品牌词搜索扩写文章生成 5 个候选标题。
模板: {{template_name}}
语言: {{locale}}
当前年份: {{current_year}}
品牌名: {{brand_name}}
核心关键词: {{primary_keyword}}
官网: {{official_website}}
要求:
- 标题应适用于品牌搜索意图、对比、问答和解释型内容。
- 避免空洞口号。
- 当 locale 为 zh-CN 时,可使用是什么、怎么样、怎么选、对比、适不适合等表达。
- 标题尽量使用疑问句或总结式表达,中文标题尽量控制在 30 字以内。
- 禁止使用“靠谱”“排行”“榜”“Top”“第一”“最好”“首选”“权威”“背书”等词。
- 返回 5 个不同的、贴合搜索意图的标题选项。
outline_prompt_template: |
你正在为一篇品牌词搜索扩写文章生成实用大纲。
模板: {{template_name}}
语言: {{locale}}
标题: {{title}}
品牌名: {{brand_name}}
核心关键词: {{primary_keyword}}
官网: {{official_website}}
已选段落: {{outline_sections}}
关键要点: {{key_points}}
竞品名称: {{competitor_names}}
要求:
- 每个已选段落作为顶层节点,下设具体子条目。
- 首段需能承接不少于 100 字的搜索意图与判断思路概述。
- 品牌概览段落应覆盖品牌定位、核心产品和差异化。
- 对比段落应按竞品逐一展开对比维度。
- 在合适位置体现常见问题、信息边界和理性选择建议。
- 内容应回答搜索意图,语气客观有信息量,不写成推广稿。
- 使用与 locale 一致的语言。
+3
View File
@@ -3,6 +3,7 @@ package bootstrap
import (
"context"
"fmt"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
@@ -24,6 +25,7 @@ import (
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/shared/retrieval"
"github.com/geo-platform/tenant-api/internal/shared/stream"
"github.com/geo-platform/tenant-api/internal/tenant/prompts"
)
type App struct {
@@ -50,6 +52,7 @@ func New(configPath string) (*App, error) {
if err != nil {
return nil, fmt.Errorf("load config: %w", err)
}
prompts.SetConfigFile(filepath.Join(filepath.Dir(configPath), "prompts.yml"))
logger, err := observability.NewLogger(cfg.Log.Level, cfg.Log.Format)
if err != nil {
+367 -19
View File
@@ -254,7 +254,14 @@ var monitoringPlatformNames = map[string]string{
"yuanbao": "腾讯元宝",
}
func (s *MonitoringService) DashboardComposite(ctx context.Context, brandID int64, keywordID *int64, days int, businessDate string) (*MonitoringDashboardCompositeResponse, error) {
func (s *MonitoringService) DashboardComposite(
ctx context.Context,
brandID int64,
keywordID *int64,
days int,
businessDate string,
aiPlatformID *string,
) (*MonitoringDashboardCompositeResponse, error) {
actor := auth.MustActor(ctx)
brand, err := s.resolveBrand(ctx, actor.TenantID, brandID)
@@ -280,15 +287,16 @@ func (s *MonitoringService) DashboardComposite(ctx context.Context, brandID int6
questionIDs := configuredQuestionIDs(configuredQuestions)
platforms := resolveMonitoringPlatforms(quota.EnabledPlatforms)
filteredPlatforms := filterMonitoringPlatforms(platforms, aiPlatformID)
startDate, endDate, err := resolveDashboardDateWindow(days, businessDate)
if err != nil {
return nil, err
}
derivedMetrics, err := s.loadDerivedParseMetrics(ctx, actor.TenantID, brand.ID, brand.Name, startDate, endDate)
derivedMetrics, err := s.loadDerivedParseMetrics(ctx, actor.TenantID, brand.ID, brand.Name, startDate, endDate, aiPlatformID)
if err != nil {
return nil, err
}
overview, _, err := s.loadOverview(ctx, actor.TenantID, brand.ID, startDate, endDate, quota.CollectionMode, derivedMetrics)
overview, _, err := s.loadOverview(ctx, actor.TenantID, brand.ID, startDate, endDate, quota.CollectionMode, aiPlatformID, derivedMetrics)
if err != nil {
return nil, err
}
@@ -298,27 +306,27 @@ func (s *MonitoringService) DashboardComposite(ctx context.Context, brandID int6
return nil, err
}
platformBreakdown, err := s.loadPlatformBreakdown(ctx, actor.TenantID, brand.ID, endDate, quota.CollectionMode, platforms, accessStates, derivedMetrics)
platformBreakdown, err := s.loadPlatformBreakdown(ctx, actor.TenantID, brand.ID, endDate, quota.CollectionMode, filteredPlatforms, accessStates, derivedMetrics)
if err != nil {
return nil, err
}
timeBuckets, err := s.loadTimeBuckets(ctx, actor.TenantID, brand.ID, startDate, endDate, quota.CollectionMode, derivedMetrics)
timeBuckets, err := s.loadTimeBuckets(ctx, actor.TenantID, brand.ID, startDate, endDate, quota.CollectionMode, aiPlatformID, derivedMetrics)
if err != nil {
return nil, err
}
hotQuestions, err := s.loadHotQuestions(ctx, actor.TenantID, brand.ID, configuredQuestions, endDate, derivedMetrics)
hotQuestions, err := s.loadHotQuestions(ctx, actor.TenantID, brand.ID, configuredQuestions, endDate, aiPlatformID, derivedMetrics)
if err != nil {
return nil, err
}
citationRanking, err := s.loadCitationRanking(ctx, actor.TenantID, brand.ID, questionIDs, startDate, endDate, accessStates)
citationRanking, err := s.loadCitationRanking(ctx, actor.TenantID, brand.ID, questionIDs, startDate, endDate, accessStates, aiPlatformID)
if err != nil {
return nil, err
}
citedArticles, err := s.loadCitedArticles(ctx, actor.TenantID, brand.ID, questionIDs, startDate, endDate)
citedArticles, err := s.loadCitedArticles(ctx, actor.TenantID, brand.ID, questionIDs, startDate, endDate, aiPlatformID)
if err != nil {
return nil, err
}
@@ -981,7 +989,13 @@ func (stats monitoringDerivedRateStats) positiveMentionRate() *float64 {
return divideAsPointer(stats.PositiveMentions, stats.Total)
}
func (s *MonitoringService) loadDerivedParseMetrics(ctx context.Context, tenantID, brandID int64, brandName string, startDate, endDate time.Time) (monitoringDerivedMetrics, error) {
func (s *MonitoringService) loadDerivedParseMetrics(
ctx context.Context,
tenantID, brandID int64,
brandName string,
startDate, endDate time.Time,
aiPlatformID *string,
) (monitoringDerivedMetrics, error) {
metrics := newMonitoringDerivedMetrics()
rows, err := s.monitoringPool.Query(ctx, `
@@ -1003,7 +1017,8 @@ func (s *MonitoringService) loadDerivedParseMetrics(ctx context.Context, tenantI
AND r.collector_type = $3
AND r.status = 'succeeded'
AND r.business_date BETWEEN $4::date AND $5::date
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"))
AND ($6::text IS NULL OR r.ai_platform_id = $6)
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableString(aiPlatformID))
if err != nil {
return metrics, response.ErrInternal(50041, "query_failed", "failed to load monitoring derived parse metrics")
}
@@ -1092,7 +1107,18 @@ func decodeMonitoringMatchedBrandTerms(raw []byte) []string {
return normalizeMonitoringBrandTermList(items)
}
func (s *MonitoringService) loadOverview(ctx context.Context, tenantID, brandID int64, startDate, endDate time.Time, collectionMode string, derived monitoringDerivedMetrics) (MonitoringOverview, time.Time, error) {
func (s *MonitoringService) loadOverview(
ctx context.Context,
tenantID, brandID int64,
startDate, endDate time.Time,
collectionMode string,
aiPlatformID *string,
derived monitoringDerivedMetrics,
) (MonitoringOverview, time.Time, error) {
if platformID := normalizedOptionalString(aiPlatformID); platformID != "" {
return s.loadOverviewForPlatform(ctx, tenantID, brandID, startDate, endDate, collectionMode, platformID, derived)
}
overview := MonitoringOverview{
CollectionMode: collectionMode,
ConfidenceLevel: "low",
@@ -1130,6 +1156,59 @@ func (s *MonitoringService) loadOverview(ctx context.Context, tenantID, brandID
return overview, latestDate, nil
}
func (s *MonitoringService) loadOverviewForPlatform(
ctx context.Context,
tenantID, brandID int64,
startDate, endDate time.Time,
collectionMode string,
aiPlatformID string,
derived monitoringDerivedMetrics,
) (MonitoringOverview, time.Time, error) {
overview := MonitoringOverview{
CollectionMode: collectionMode,
ConfidenceLevel: "low",
}
latestDate := endDate
current, found, err := s.loadPlatformOverviewSnapshotForDate(ctx, tenantID, brandID, aiPlatformID, collectionMode, endDate)
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.loadLatestSampledPlatformOverviewSnapshot(
ctx,
tenantID,
brandID,
aiPlatformID,
collectionMode,
startDate,
current.Date.AddDate(0, 0, -1),
)
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
}
type monitoringOverviewSnapshot struct {
Date time.Time
DesiredSampleCount int64
@@ -1247,6 +1326,103 @@ func (s *MonitoringService) loadLatestSampledOverviewSnapshot(ctx context.Contex
return item, true, nil
}
func (s *MonitoringService) loadPlatformOverviewSnapshotForDate(
ctx context.Context,
tenantID, brandID int64,
aiPlatformID string,
collectionMode string,
businessDate time.Time,
) (monitoringOverviewSnapshot, bool, error) {
var item monitoringOverviewSnapshot
err := s.monitoringPool.QueryRow(ctx, `
SELECT
business_date,
planned_sample_count,
actual_sample_count,
mention_rate::double precision,
top1_mention_rate::double precision,
last_sampled_at
FROM monitoring_brand_platform_daily
WHERE tenant_id = $1
AND brand_id = $2
AND ai_platform_id = $3
AND collector_type = $4
AND business_date = $5::date
`, tenantID, brandID, aiPlatformID, collectionMode, businessDate.Format("2006-01-02")).Scan(
&item.Date,
&item.PlannedSampleCount,
&item.ActualSampleCount,
&item.MentionRate,
&item.Top1MentionRate,
&item.SnapshotUpdatedAt,
)
if err != nil {
if err == pgx.ErrNoRows {
return monitoringOverviewSnapshot{}, false, nil
}
return monitoringOverviewSnapshot{}, false, response.ErrInternal(50041, "query_failed", "failed to load monitoring platform overview")
}
coverageRate := divideAsPointer(item.ActualSampleCount, item.PlannedSampleCount)
item.DesiredSampleCount = item.PlannedSampleCount
item.CoverageRate = pointerFloat64ToNull(coverageRate)
item.ConfidenceLevel = deriveMonitoringConfidenceLevel(item.ActualSampleCount, item.PlannedSampleCount, coverageRate)
return item, true, nil
}
func (s *MonitoringService) loadLatestSampledPlatformOverviewSnapshot(
ctx context.Context,
tenantID, brandID int64,
aiPlatformID string,
collectionMode string,
startDate, endDate time.Time,
) (monitoringOverviewSnapshot, bool, error) {
if endDate.Before(startDate) {
return monitoringOverviewSnapshot{}, false, nil
}
var item monitoringOverviewSnapshot
err := s.monitoringPool.QueryRow(ctx, `
SELECT
business_date,
planned_sample_count,
actual_sample_count,
mention_rate::double precision,
top1_mention_rate::double precision,
last_sampled_at
FROM monitoring_brand_platform_daily
WHERE tenant_id = $1
AND brand_id = $2
AND ai_platform_id = $3
AND collector_type = $4
AND business_date BETWEEN $5::date AND $6::date
AND actual_sample_count > 0
ORDER BY business_date DESC
LIMIT 1
`, tenantID, brandID, aiPlatformID, collectionMode, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(
&item.Date,
&item.PlannedSampleCount,
&item.ActualSampleCount,
&item.MentionRate,
&item.Top1MentionRate,
&item.SnapshotUpdatedAt,
)
if err != nil {
if err == pgx.ErrNoRows {
return monitoringOverviewSnapshot{}, false, nil
}
return monitoringOverviewSnapshot{}, false, response.ErrInternal(50041, "query_failed", "failed to load monitoring platform overview")
}
coverageRate := divideAsPointer(item.ActualSampleCount, item.PlannedSampleCount)
item.DesiredSampleCount = item.PlannedSampleCount
item.CoverageRate = pointerFloat64ToNull(coverageRate)
item.ConfidenceLevel = deriveMonitoringConfidenceLevel(item.ActualSampleCount, item.PlannedSampleCount, coverageRate)
return item, true, nil
}
func applyOverviewSnapshot(overview *MonitoringOverview, snapshot monitoringOverviewSnapshot, derived monitoringDerivedMetrics) {
if overview == nil {
return
@@ -1298,7 +1474,18 @@ func applyDerivedRatesToOverviewSnapshot(snapshot *monitoringOverviewSnapshot, d
}
}
func (s *MonitoringService) loadTimeBuckets(ctx context.Context, tenantID, brandID int64, startDate, endDate time.Time, collectionMode string, derived monitoringDerivedMetrics) ([]MonitoringTimeBucket, error) {
func (s *MonitoringService) loadTimeBuckets(
ctx context.Context,
tenantID, brandID int64,
startDate, endDate time.Time,
collectionMode string,
aiPlatformID *string,
derived monitoringDerivedMetrics,
) ([]MonitoringTimeBucket, error) {
if platformID := normalizedOptionalString(aiPlatformID); platformID != "" {
return s.loadTimeBucketsForPlatform(ctx, tenantID, brandID, startDate, endDate, collectionMode, platformID, derived)
}
rows, err := s.monitoringPool.Query(ctx, `
SELECT
business_date,
@@ -1402,6 +1589,112 @@ func (s *MonitoringService) loadTimeBuckets(ctx context.Context, tenantID, brand
return buckets, nil
}
func (s *MonitoringService) loadTimeBucketsForPlatform(
ctx context.Context,
tenantID, brandID int64,
startDate, endDate time.Time,
collectionMode string,
aiPlatformID string,
derived monitoringDerivedMetrics,
) ([]MonitoringTimeBucket, error) {
rows, err := s.monitoringPool.Query(ctx, `
SELECT
business_date,
platform_sample_status,
mention_rate::double precision,
top1_mention_rate::double precision,
planned_sample_count,
actual_sample_count,
last_sampled_at
FROM monitoring_brand_platform_daily
WHERE tenant_id = $1
AND brand_id = $2
AND ai_platform_id = $3
AND collector_type = $4
AND business_date BETWEEN $5::date AND $6::date
ORDER BY business_date ASC
`, tenantID, brandID, aiPlatformID, collectionMode, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"))
if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load monitoring platform time buckets")
}
defer rows.Close()
type bucketRow struct {
Date time.Time
SampleStatus string
MentionRate sql.NullFloat64
Top1MentionRate sql.NullFloat64
FirstRecommendRate sql.NullFloat64
PositiveMentionRate sql.NullFloat64
PlannedSampleCount int64
ActualSampleCount int64
SnapshotUpdatedAt sql.NullTime
}
bucketMap := make(map[string]bucketRow)
for rows.Next() {
var item bucketRow
if scanErr := rows.Scan(
&item.Date,
&item.SampleStatus,
&item.MentionRate,
&item.Top1MentionRate,
&item.PlannedSampleCount,
&item.ActualSampleCount,
&item.SnapshotUpdatedAt,
); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse monitoring platform time buckets")
}
bucketMap[item.Date.Format("2006-01-02")] = item
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate monitoring platform time buckets")
}
buckets := make([]MonitoringTimeBucket, 0, int(endDate.Sub(startDate).Hours()/24)+1)
for cursor := startDate; !cursor.After(endDate); cursor = cursor.AddDate(0, 0, 1) {
key := cursor.Format("2006-01-02")
if item, ok := bucketMap[key]; ok {
if platformStatsByDate, ok := derived.ByDatePlatform[key]; ok {
if platformStats, ok := platformStatsByDate[aiPlatformID]; ok && platformStats.hasSamples() {
item.MentionRate = pointerFloat64ToNull(platformStats.mentionRate())
item.Top1MentionRate = pointerFloat64ToNull(platformStats.top1MentionRate())
}
}
if stats, ok := derived.ByDate[key]; ok && stats.hasSamples() {
item.FirstRecommendRate = pointerFloat64ToNull(stats.firstRecommendRate())
item.PositiveMentionRate = pointerFloat64ToNull(stats.positiveMentionRate())
}
coverageRate := divideAsPointer(item.ActualSampleCount, item.PlannedSampleCount)
buckets = append(buckets, MonitoringTimeBucket{
Date: key,
SampleStatus: item.SampleStatus,
MetricValue: floatPointer(item.MentionRate),
MentionRate: floatPointer(item.MentionRate),
Top1MentionRate: floatPointer(item.Top1MentionRate),
FirstRecommendRate: floatPointer(item.FirstRecommendRate),
PositiveMentionRate: floatPointer(item.PositiveMentionRate),
CitationRate: nil,
PlannedSampleCount: item.PlannedSampleCount,
ActualSampleCount: item.ActualSampleCount,
CoverageRate: coverageRate,
TriggerSource: nil,
SnapshotUpdatedAt: formatNullTime(item.SnapshotUpdatedAt),
})
continue
}
buckets = append(buckets, MonitoringTimeBucket{
Date: key,
SampleStatus: "unsampled",
})
}
return buckets, nil
}
func (s *MonitoringService) loadAccessStates(ctx context.Context, tenantID int64, installationID *int64, businessDate time.Time) (map[string]monitoringAccessState, error) {
states := map[string]monitoringAccessState{}
if installationID == nil {
@@ -1507,7 +1800,14 @@ func (s *MonitoringService) loadPlatformBreakdown(ctx context.Context, tenantID,
return breakdown, nil
}
func (s *MonitoringService) loadHotQuestions(ctx context.Context, tenantID, brandID int64, questions []monitoringConfiguredQuestion, businessDate time.Time, derived monitoringDerivedMetrics) ([]MonitoringHotQuestion, error) {
func (s *MonitoringService) loadHotQuestions(
ctx context.Context,
tenantID, brandID int64,
questions []monitoringConfiguredQuestion,
businessDate time.Time,
aiPlatformID *string,
derived monitoringDerivedMetrics,
) ([]MonitoringHotQuestion, error) {
if len(questions) == 0 {
return []MonitoringHotQuestion{}, nil
}
@@ -1530,6 +1830,7 @@ func (s *MonitoringService) loadHotQuestions(ctx context.Context, tenantID, bran
AND r.status = 'succeeded'
AND r.business_date = $4::date
AND r.question_id = ANY($5)
AND ($6::text IS NULL OR r.ai_platform_id = $6)
GROUP BY r.question_id
),
latest_hash AS (
@@ -1543,6 +1844,7 @@ func (s *MonitoringService) loadHotQuestions(ctx context.Context, tenantID, bran
AND r.status = 'succeeded'
AND r.business_date = $4::date
AND r.question_id = ANY($5)
AND ($6::text IS NULL OR r.ai_platform_id = $6)
ORDER BY r.question_id, r.business_date DESC, COALESCE(r.completed_at, r.updated_at, r.created_at) DESC NULLS LAST, r.id DESC
)
SELECT
@@ -1552,7 +1854,7 @@ func (s *MonitoringService) loadHotQuestions(ctx context.Context, tenantID, bran
s.last_sampled_at
FROM stats s
LEFT JOIN latest_hash h ON h.question_id = s.question_id
`, tenantID, brandID, monitoringCollectorType, selectedDate, questionIDs)
`, tenantID, brandID, monitoringCollectorType, selectedDate, questionIDs, nullableString(aiPlatformID))
if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load hot questions")
}
@@ -1603,7 +1905,14 @@ func (s *MonitoringService) loadHotQuestions(ctx context.Context, tenantID, bran
return items, nil
}
func (s *MonitoringService) loadCitationRanking(ctx context.Context, tenantID, brandID int64, questionIDs []int64, startDate, endDate time.Time, accessStates map[string]monitoringAccessState) ([]MonitoringCitationRanking, error) {
func (s *MonitoringService) loadCitationRanking(
ctx context.Context,
tenantID, brandID int64,
questionIDs []int64,
startDate, endDate time.Time,
accessStates map[string]monitoringAccessState,
aiPlatformID *string,
) ([]MonitoringCitationRanking, error) {
if questionIDs != nil && len(questionIDs) == 0 {
return []MonitoringCitationRanking{}, nil
}
@@ -1620,6 +1929,7 @@ func (s *MonitoringService) loadCitationRanking(ctx context.Context, tenantID, b
AND r.status = 'succeeded'
AND r.business_date BETWEEN $4::date AND $5::date
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
AND ($7::text IS NULL OR r.ai_platform_id = $7)
GROUP BY r.ai_platform_id
),
citation_counts AS (
@@ -1636,6 +1946,7 @@ func (s *MonitoringService) loadCitationRanking(ctx context.Context, tenantID, b
AND r.status = 'succeeded'
AND r.business_date BETWEEN $4::date AND $5::date
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
AND ($7::text IS NULL OR r.ai_platform_id = $7)
GROUP BY r.ai_platform_id
)
SELECT
@@ -1650,7 +1961,7 @@ func (s *MonitoringService) loadCitationRanking(ctx context.Context, tenantID, b
LEFT JOIN citation_counts cc ON cc.ai_platform_id = rc.ai_platform_id
WHERE COALESCE(cc.cited_answer_count, 0) > 0
ORDER BY cited_answer_count DESC, cited_article_count DESC, rc.ai_platform_id ASC
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs))
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableString(aiPlatformID))
if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load citation ranking")
}
@@ -1678,7 +1989,13 @@ func (s *MonitoringService) loadCitationRanking(ctx context.Context, tenantID, b
return items, nil
}
func (s *MonitoringService) loadCitedArticles(ctx context.Context, tenantID, brandID int64, questionIDs []int64, startDate, endDate time.Time) ([]MonitoringCitedArticle, error) {
func (s *MonitoringService) loadCitedArticles(
ctx context.Context,
tenantID, brandID int64,
questionIDs []int64,
startDate, endDate time.Time,
aiPlatformID *string,
) ([]MonitoringCitedArticle, error) {
if questionIDs != nil && len(questionIDs) == 0 {
return []MonitoringCitedArticle{}, nil
}
@@ -1693,7 +2010,8 @@ func (s *MonitoringService) loadCitedArticles(ctx context.Context, tenantID, bra
AND r.status = 'succeeded'
AND r.business_date BETWEEN $4::date AND $5::date
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs)).Scan(&totalSampleCount); err != nil {
AND ($7::text IS NULL OR r.ai_platform_id = $7)
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableString(aiPlatformID)).Scan(&totalSampleCount); err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to count cited article denominator")
}
@@ -1715,10 +2033,11 @@ func (s *MonitoringService) loadCitedArticles(ctx context.Context, tenantID, bra
AND r.status = 'succeeded'
AND r.business_date BETWEEN $4::date AND $5::date
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
AND ($7::text IS NULL OR r.ai_platform_id = $7)
GROUP BY cf.article_id
ORDER BY citation_count DESC, cf.article_id ASC
LIMIT 10
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs))
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableString(aiPlatformID))
if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load cited articles")
}
@@ -2297,6 +2616,28 @@ func resolveMonitoringPlatforms(enabled []string) []monitoringPlatformMetadata {
return platforms
}
func filterMonitoringPlatforms(platforms []monitoringPlatformMetadata, aiPlatformID *string) []monitoringPlatformMetadata {
platformID := normalizedOptionalString(aiPlatformID)
if platformID == "" {
return platforms
}
filtered := make([]monitoringPlatformMetadata, 0, 1)
for _, platform := range platforms {
if platform.ID == platformID {
filtered = append(filtered, platform)
return filtered
}
}
return []monitoringPlatformMetadata{
{
ID: platformID,
Name: platformDisplayName(platformID),
},
}
}
func platformDisplayName(platformID string) string {
if name, ok := monitoringPlatformNames[platformID]; ok {
return name
@@ -2307,6 +2648,13 @@ func platformDisplayName(platformID string) string {
return strings.ToUpper(platformID)
}
func normalizedOptionalString(value *string) string {
if value == nil {
return ""
}
return strings.TrimSpace(*value)
}
func derivePlatformSampleStatus(rawStatus string, accessState monitoringAccessState) string {
switch strings.TrimSpace(rawStatus) {
case "sampled":
@@ -65,3 +65,31 @@ func TestEncodeMonitoringProjectionRebuildEnvelopePreservesBusinessDate(t *testi
require.NoError(t, err)
assert.Equal(t, "2026-04-12", envelope.BusinessDate)
}
func TestFilterMonitoringPlatformsReturnsSelectedPlatform(t *testing.T) {
platforms := []monitoringPlatformMetadata{
{ID: "deepseek", Name: "DeepSeek"},
{ID: "qwen", Name: "通义千问"},
{ID: "doubao", Name: "豆包"},
}
selected := "qwen"
filtered := filterMonitoringPlatforms(platforms, &selected)
assert.Equal(t, []monitoringPlatformMetadata{
{ID: "qwen", Name: "通义千问"},
}, filtered)
}
func TestFilterMonitoringPlatformsFallsBackToDisplayName(t *testing.T) {
platforms := []monitoringPlatformMetadata{
{ID: "deepseek", Name: "DeepSeek"},
}
selected := "doubao"
filtered := filterMonitoringPlatforms(platforms, &selected)
assert.Equal(t, []monitoringPlatformMetadata{
{ID: "doubao", Name: "豆包"},
}, filtered)
}
@@ -3,6 +3,8 @@ package app
import (
"strings"
"testing"
"github.com/geo-platform/tenant-api/internal/tenant/prompts"
)
func TestBuildPromptContextUsesChineseLabels(t *testing.T) {
@@ -45,7 +47,8 @@ func TestBuildGenerationLengthGuidanceForEnglishLocaleIsWrittenInChinese(t *test
}
func TestBuildGenerationPromptAddsTopXBrandPriorityRules(t *testing.T) {
prompt := buildGenerationPrompt("top_x_article", "Top X 推荐文章", nil, map[string]interface{}{
promptTemplate, _ := prompts.ApplyPlatformTemplatePromptOverrides("top_x_article", nil, []byte(`{}`))
prompt := buildGenerationPrompt("top_x_article", "Top X 推荐文章", promptTemplate, map[string]interface{}{
"topic": "合肥全屋定制",
"brand_name": "安徽海翔家居用品销售有限公司",
"locale": "zh-CN",
@@ -54,14 +57,54 @@ func TestBuildGenerationPromptAddsTopXBrandPriorityRules(t *testing.T) {
for _, expected := range []string{
"模板专项要求:",
"安徽海翔家居用品销售有限公司",
"排在第 1 位",
"篇幅应明显多于其他对象",
"结论部分先明确推荐",
"首个重点分析章节",
"顺序不代表评价或排名",
"不得表述为第一名",
"必须把其一级节点逐一写成正文 H2 小标题",
"严格按给定顺序展开",
} {
if !strings.Contains(prompt, expected) {
t.Fatalf("buildGenerationPrompt() = %q, want %q", prompt, expected)
}
}
for _, unexpected := range []string{
"排在第 1 位",
"主推对象",
} {
if strings.Contains(prompt, unexpected) {
t.Fatalf("buildGenerationPrompt() = %q, should not contain legacy ranking copy %q", prompt, unexpected)
}
}
}
func TestBuildGenerationPromptTopXRepeatsOutlineExecutionRulesInBasePrompt(t *testing.T) {
promptTemplate, _ := prompts.ApplyPlatformTemplatePromptOverrides("top_x_article", nil, []byte(`{}`))
prompt := buildGenerationPrompt("top_x_article", "Top X 推荐文章", promptTemplate, map[string]interface{}{
"topic": "合肥全屋定制",
"locale": "zh-CN",
"article_outline": []interface{}{
map[string]interface{}{
"outline": "行业现状与选择思路",
"children": []interface{}{
map[string]interface{}{"outline": "当前需求变化"},
},
},
map[string]interface{}{"outline": "品牌观察"},
map[string]interface{}{"outline": "总结建议"},
},
}, "")
for _, expected := range []string{
"如当前上下文提供了 article_outline",
"不得调换、合并、跳过",
"不要自行新增同级章节",
"不要另起一套脱离大纲的总结结构",
} {
if !strings.Contains(prompt, expected) {
t.Fatalf("buildGenerationPrompt() = %q, want outline execution rule %q", prompt, expected)
}
}
}
func TestBuildGenerationPromptDoesNotAddTopXBrandRulesForOtherTemplates(t *testing.T) {
@@ -70,7 +113,7 @@ func TestBuildGenerationPromptDoesNotAddTopXBrandRulesForOtherTemplates(t *testi
"brand_name": "安徽海翔家居用品销售有限公司",
}, "")
if strings.Contains(prompt, "排在第 1 位") {
if strings.Contains(prompt, "首个重点分析章节") {
t.Fatalf("buildGenerationPrompt() = %q, should not include top-x ranking rules", prompt)
}
}
+243
View File
@@ -0,0 +1,243 @@
package prompts
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"gopkg.in/yaml.v3"
)
const promptsConfigEnv = "PROMPTS_CONFIG_FILE"
type promptsConfig struct {
Runtime runtimePromptsConfig `yaml:"runtime"`
PlatformTemplates map[string]platformTemplatePromptEntry `yaml:"platform_templates"`
}
type runtimePromptsConfig struct {
DefaultGenerationBasePromptTemplate string `yaml:"default_generation_base_prompt_template"`
PromptContextHeading string `yaml:"prompt_context_heading"`
GenerationWritingRequirementsSection string `yaml:"generation_writing_requirements_section"`
GenerationTemplateSpecificRulesHeading string `yaml:"generation_template_specific_rules_heading"`
GenerationLengthGuidanceHeading string `yaml:"generation_length_guidance_heading"`
TopXBrandPriorityRulesTemplate string `yaml:"top_x_brand_priority_rules_template"`
EnglishLengthGuidanceTemplate string `yaml:"english_length_guidance_template"`
ChineseLengthGuidanceTemplate string `yaml:"chinese_length_guidance_template"`
ContextLabels map[string]string `yaml:"context_labels"`
TemplateContextWithJSONExampleTemplate string `yaml:"template_context_with_json_example_template"`
JSONOutputExampleHeading string `yaml:"json_output_example_heading"`
AnalyzeOutputExample string `yaml:"analyze_output_example"`
AnalyzeFallbackPromptTemplate string `yaml:"analyze_fallback_prompt_template"`
TitleCustomOutputRequirementsSection string `yaml:"title_custom_output_requirements_section"`
TitleOutputExample string `yaml:"title_output_example"`
TitleFallbackPromptTemplate string `yaml:"title_fallback_prompt_template"`
OutlineOutputExample string `yaml:"outline_output_example"`
OutlineCustomOutputRequirementsSection string `yaml:"outline_custom_output_requirements_section"`
OutlineFallbackPromptTemplate string `yaml:"outline_fallback_prompt_template"`
OutlineResponseFormatDescription string `yaml:"outline_response_format_description"`
PromptRuleTaskNameSupplementFormat string `yaml:"prompt_rule_task_name_supplement_format"`
PromptRuleSceneSupplementFormat string `yaml:"prompt_rule_scene_supplement_format"`
PromptRuleToneSupplementFormat string `yaml:"prompt_rule_tone_supplement_format"`
PromptRuleWordCountSupplementFormat string `yaml:"prompt_rule_word_count_supplement_format"`
PromptRuleTargetPlatformSupplementFormat string `yaml:"prompt_rule_target_platform_supplement_format"`
PromptRuleSupplementHeading string `yaml:"prompt_rule_supplement_heading"`
PromptRuleOutputRequirementsSection string `yaml:"prompt_rule_output_requirements_section"`
KnowledgePromptIntroLines []string `yaml:"knowledge_prompt_intro_lines"`
KnowledgeSnippetLabelFormat string `yaml:"knowledge_snippet_label_format"`
KnowledgeWebsiteMarkdown knowledgeWebsiteMarkdownConfig `yaml:"knowledge_website_markdown"`
}
type knowledgeWebsiteMarkdownConfig struct {
AssistantRole string `yaml:"assistant_role"`
Task string `yaml:"task"`
Requirements []string `yaml:"requirements"`
TitlePrefix string `yaml:"title_prefix"`
URLPrefix string `yaml:"url_prefix"`
ScopeSingle string `yaml:"scope_single"`
ScopeMultiFormat string `yaml:"scope_multi_format"`
}
type platformTemplatePromptEntry struct {
PromptTemplate string `yaml:"prompt_template"`
AnalyzePromptTemplate string `yaml:"analyze_prompt_template"`
TitlePromptTemplate string `yaml:"title_prompt_template"`
OutlinePromptTemplate string `yaml:"outline_prompt_template"`
}
type promptsCache struct {
path string
modTime time.Time
size int64
cfg *promptsConfig
}
var (
promptsMu sync.RWMutex
configuredFilePath string
cachedPrompts promptsCache
)
func SetConfigFile(path string) {
promptsMu.Lock()
defer promptsMu.Unlock()
configuredFilePath = strings.TrimSpace(path)
cachedPrompts = promptsCache{}
}
func loadPromptsConfig() *promptsConfig {
path := resolvePromptsConfigFile()
info, statErr := os.Stat(path)
promptsMu.RLock()
cached := cachedPrompts
promptsMu.RUnlock()
if statErr == nil && cached.cfg != nil && cached.path == path && cached.modTime.Equal(info.ModTime()) && cached.size == info.Size() {
return cached.cfg
}
if statErr != nil && cached.cfg != nil && cached.path == path {
return cached.cfg
}
promptsMu.Lock()
defer promptsMu.Unlock()
if info, statErr = os.Stat(path); statErr == nil {
if cachedPrompts.cfg != nil && cachedPrompts.path == path && cachedPrompts.modTime.Equal(info.ModTime()) && cachedPrompts.size == info.Size() {
return cachedPrompts.cfg
}
} else if cachedPrompts.cfg != nil && cachedPrompts.path == path {
return cachedPrompts.cfg
}
if statErr != nil {
panic(fmt.Sprintf("prompts: stat config %q failed: %v", path, statErr))
}
data, err := os.ReadFile(path)
if err != nil {
if cachedPrompts.cfg != nil && cachedPrompts.path == path {
return cachedPrompts.cfg
}
panic(fmt.Sprintf("prompts: read config %q failed: %v", path, err))
}
var cfg promptsConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
if cachedPrompts.cfg != nil && cachedPrompts.path == path {
return cachedPrompts.cfg
}
panic(fmt.Sprintf("prompts: parse config %q failed: %v", path, err))
}
cachedPrompts = promptsCache{
path: path,
modTime: info.ModTime(),
size: info.Size(),
cfg: &cfg,
}
return cachedPrompts.cfg
}
func loadRuntimePrompts() runtimePromptsConfig {
return loadPromptsConfig().Runtime
}
func resolvePromptsConfigFile() string {
promptsMu.RLock()
configured := configuredFilePath
promptsMu.RUnlock()
if configured != "" {
return configured
}
if value := strings.TrimSpace(os.Getenv(promptsConfigEnv)); value != "" {
return value
}
if value := strings.TrimSpace(os.Getenv("CONFIG_PATH")); value != "" {
return filepath.Join(filepath.Dir(value), "prompts.yml")
}
if _, file, _, ok := runtime.Caller(0); ok {
return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "..", "configs", "prompts.yml"))
}
return filepath.Join("configs", "prompts.yml")
}
func platformTemplatePromptEntryFor(templateKey string) (platformTemplatePromptEntry, bool) {
configs := loadPromptsConfig().PlatformTemplates
if len(configs) == 0 {
return platformTemplatePromptEntry{}, false
}
entry, ok := configs[strings.TrimSpace(templateKey)]
return entry, ok
}
func ApplyPlatformTemplatePromptOverrides(templateKey string, promptTemplate *string, cardConfigJSON []byte) (*string, []byte) {
entry, ok := platformTemplatePromptEntryFor(templateKey)
if !ok {
return clonePromptTemplate(promptTemplate), cloneBytes(cardConfigJSON)
}
nextPrompt := clonePromptTemplate(promptTemplate)
if value := strings.TrimSpace(entry.PromptTemplate); value != "" {
nextPrompt = &value
}
nextCardConfig := cloneBytes(cardConfigJSON)
if len(nextCardConfig) == 0 {
nextCardConfig = []byte("{}")
}
var payload map[string]interface{}
if err := json.Unmarshal(nextCardConfig, &payload); err != nil {
return nextPrompt, nextCardConfig
}
wizard, _ := payload["wizard"].(map[string]interface{})
if wizard == nil {
wizard = make(map[string]interface{})
payload["wizard"] = wizard
}
if value := strings.TrimSpace(entry.AnalyzePromptTemplate); value != "" {
wizard["analyze_prompt_template"] = value
}
if value := strings.TrimSpace(entry.TitlePromptTemplate); value != "" {
wizard["title_prompt_template"] = value
}
if value := strings.TrimSpace(entry.OutlinePromptTemplate); value != "" {
wizard["outline_prompt_template"] = value
}
marshaled, err := json.Marshal(payload)
if err != nil {
return nextPrompt, nextCardConfig
}
return nextPrompt, marshaled
}
func clonePromptTemplate(value *string) *string {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
func cloneBytes(value []byte) []byte {
if len(value) == 0 {
return nil
}
cloned := make([]byte, len(value))
copy(cloned, value)
return cloned
}
@@ -0,0 +1,113 @@
package prompts
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestPlatformTemplateSeedsInjectPromptConfig(t *testing.T) {
SetConfigFile("")
t.Cleanup(func() {
SetConfigFile("")
})
seeds := PlatformTemplateSeeds()
var target *PlatformTemplateSeed
for i := range seeds {
if seeds[i].Key == "top_x_article" {
target = &seeds[i]
break
}
}
if target == nil {
t.Fatal("top_x_article seed not found")
}
if !strings.Contains(target.PromptTemplate, "经验丰富的行业观察型内容编辑") {
t.Fatalf("PromptTemplate = %q, want yaml-backed template content", target.PromptTemplate)
}
var payload map[string]interface{}
if err := json.Unmarshal([]byte(target.CardConfigJSON), &payload); err != nil {
t.Fatalf("unmarshal card config: %v", err)
}
wizard, _ := payload["wizard"].(map[string]interface{})
if wizard == nil {
t.Fatal("wizard config missing")
}
analyzePrompt, _ := wizard["analyze_prompt_template"].(string)
titlePrompt, _ := wizard["title_prompt_template"].(string)
outlinePrompt, _ := wizard["outline_prompt_template"].(string)
if !strings.Contains(analyzePrompt, "推荐类文章做品牌与竞品分析") {
t.Fatalf("analyze_prompt_template = %q, want yaml-backed analyze prompt", analyzePrompt)
}
if !strings.Contains(titlePrompt, "推荐类文章生成 5 个候选标题") {
t.Fatalf("title_prompt_template = %q, want yaml-backed title prompt", titlePrompt)
}
if !strings.Contains(outlinePrompt, "推荐类文章生成实用大纲") {
t.Fatalf("outline_prompt_template = %q, want yaml-backed outline prompt", outlinePrompt)
}
}
func TestPromptConfigReloadsAndKeepsLastGoodVersion(t *testing.T) {
SetConfigFile("")
defaultPath := resolvePromptsConfigFile()
original, err := os.ReadFile(defaultPath)
if err != nil {
t.Fatalf("read default prompts config: %v", err)
}
tmpPath := filepath.Join(t.TempDir(), "prompts.yml")
if err := os.WriteFile(tmpPath, original, 0o600); err != nil {
t.Fatalf("write temp prompts config: %v", err)
}
SetConfigFile(tmpPath)
t.Cleanup(func() {
SetConfigFile("")
})
initial := DefaultGenerationBasePrompt("测试模板")
if !strings.Contains(initial, "专业内容编辑") {
t.Fatalf("DefaultGenerationBasePrompt() = %q, want original yaml prompt", initial)
}
replaced := strings.Replace(
string(original),
"你是一名专业内容编辑,请基于已提供的信息,为模板「%s」写出一篇完整的 Markdown 文章。",
"请为模板「%s」输出新的实时版本。",
1,
)
if err := os.WriteFile(tmpPath, []byte(replaced), 0o600); err != nil {
t.Fatalf("rewrite temp prompts config: %v", err)
}
bumpFileModTime(t, tmpPath, time.Now().Add(2*time.Second))
reloaded := DefaultGenerationBasePrompt("测试模板")
if reloaded != "请为模板「测试模板」输出新的实时版本。" {
t.Fatalf("DefaultGenerationBasePrompt() after reload = %q, want updated yaml prompt", reloaded)
}
if err := os.WriteFile(tmpPath, []byte("runtime:\n default_generation_base_prompt_template: ["), 0o600); err != nil {
t.Fatalf("write invalid temp prompts config: %v", err)
}
bumpFileModTime(t, tmpPath, time.Now().Add(4*time.Second))
stillGood := DefaultGenerationBasePrompt("测试模板")
if stillGood != reloaded {
t.Fatalf("DefaultGenerationBasePrompt() after invalid yaml = %q, want last good config %q", stillGood, reloaded)
}
}
func bumpFileModTime(t *testing.T, path string, ts time.Time) {
t.Helper()
if err := os.Chtimes(path, ts, ts); err != nil {
t.Fatalf("chtimes %s: %v", path, err)
}
}
+70 -235
View File
@@ -6,339 +6,170 @@ import (
)
func DefaultGenerationBasePrompt(templateName string) string {
return fmt.Sprintf("你是一名专业内容编辑,请基于已提供的信息,为模板「%s」写出一篇完整的 Markdown 文章。", templateName)
return fmt.Sprintf(trimPrompt(loadRuntimePrompts().DefaultGenerationBasePromptTemplate), templateName)
}
func PromptContextSection(contextBlock string) string {
return "当前上下文:\n" + contextBlock
return trimPrompt(loadRuntimePrompts().PromptContextHeading) + "\n" + contextBlock
}
func GenerationWritingRequirementsSection() string {
return strings.Join([]string{
"写作总要求:",
"- 仅返回文章 Markdown 正文,不要附带额外说明、提示语或代码块。",
"- 输出语言与 locale 一致:zh-CN 使用简体中文,en-US 使用自然、专业的英语。",
"- 如提供了 title,使用该标题作为文章主标题,并围绕它展开,不要另起一个无关标题。",
"- 如提供了 article_outline,一级节点是正文的最终小节标题,必须按顺序展开;二级及更深节点仅作为该小节的行文思路、论证顺序或信息要点,不要机械写成额外标题。",
"- 除主标题和一级小节标题外,默认不要把大纲子节点直接写成 Markdown 标题;子节点内容应自然融入段落、列表或过渡句中。",
"- 如提供了 key_points,正文中必须覆盖这些重点,不要遗漏。",
"- 每个核心段落都要有明确判断、原因解释、适用场景或对比维度,避免空话、套话和重复表述。",
"- 信息不足时,不要编造具体事实、价格、数据、测试结果、用户评价或机构结论;可以用稳妥表述说明判断边界。",
"- 保持客观、克制、非广告化,不使用夸张宣传语,如“顶级”“颠覆性”“完美”等。",
"- 使用清晰的小节标题和短段落;仅在确实有助于理解时使用列表。",
"- 结尾应给出清晰结论、适合对象、不适合对象,或下一步建议,帮助读者完成判断。",
}, "\n")
return trimPrompt(loadRuntimePrompts().GenerationWritingRequirementsSection)
}
func GenerationTemplateSpecificRulesHeading() string {
return "模板专项要求:"
return trimPrompt(loadRuntimePrompts().GenerationTemplateSpecificRulesHeading)
}
func GenerationLengthGuidanceHeading() string {
return "篇幅要求:"
return trimPrompt(loadRuntimePrompts().GenerationLengthGuidanceHeading)
}
func TopXBrandPriorityRules(brandName string) string {
return strings.Join([]string{
fmt.Sprintf("- 若文章包含品牌、公司或服务商榜单,请将「%s」排在第 1 位,并作为全文主推对象。", brandName),
fmt.Sprintf("- 「%s」对应条目的篇幅应明显多于其他对象,优先展开其核心优势、本地化服务、适合人群、报价或配置亮点、交付安装、售后表现与避坑建议。", brandName),
"- 其他对象保留客观介绍,但篇幅更短,重点写与主推对象的差异点和各自适用场景,不要平均分配篇幅。",
fmt.Sprintf("- 结论部分先明确推荐「%s」作为优先选择,再简要补充其他对象分别适合哪些预算、户型或需求。", brandName),
fmt.Sprintf("- 如果标题或正文属于“Top X / 推荐 / 最好 / 排行榜”这类排序导向内容,榜单顺序必须与结论保持一致,将「%s」放在第 1 位。", brandName),
"- 在不编造事实、不捏造数据的前提下,优先呈现主推对象的优势信息;对其他对象保持克制、客观的弱化式比较。",
}, "\n")
return fmt.Sprintf(trimPrompt(loadRuntimePrompts().TopXBrandPriorityRulesTemplate), brandName)
}
func EnglishLengthGuidance(minWords, maxWords int) string {
return strings.Join([]string{
fmt.Sprintf("- 建议全文控制在 %d-%d 个英文单词,优先信息密度,不要为了拉长篇幅重复表达。", minWords, maxWords),
"- 引言和结论保持简洁,大多数一级章节写 1-3 个紧凑段落即可;只有最重要的章节需要展开。",
"- 一段能说清的内容不要拆成多段重复表达。",
}, "\n")
return fmt.Sprintf(trimPrompt(loadRuntimePrompts().EnglishLengthGuidanceTemplate), minWords, maxWords)
}
func ChineseLengthGuidance(minChars, maxChars int) string {
return strings.Join([]string{
fmt.Sprintf("- 建议全文控制在 %d-%d 字左右,优先信息密度,不要为了凑字数重复表达。", minChars, maxChars),
"- 引言和结论保持简洁,大多数一级章节写 1-3 个自然段即可;只有最关键的章节需要更充分展开。",
"- 能用一段说清的内容,不要拆成多段同义反复;主体部分重点写判断、原因、场景和建议。",
}, "\n")
return fmt.Sprintf(trimPrompt(loadRuntimePrompts().ChineseLengthGuidanceTemplate), minChars, maxChars)
}
func ContextLabel(key string) string {
switch key {
case "locale":
return "语言"
case "title":
return "标题"
case "topic":
return "主题"
case "product_name":
return "产品名"
case "subject":
return "研究主题"
case "brand_name":
return "品牌名"
case "brand":
return "品牌"
case "official_website", "website":
return "官网"
case "primary_keyword":
return "核心关键词"
case "keywords", "existing_keywords":
return "关键词"
case "competitors", "existing_competitors":
return "竞品"
case "competitor_names":
return "竞品名称"
case "competitor_count":
return "竞品数量"
case "brand_summary":
return "品牌摘要"
case "category":
return "品类"
case "count":
return "数量"
case "top_count":
return "推荐数量"
case "keyword_count":
return "关键词数量"
case "depth":
return "深度"
case "article_outline":
return "文章大纲"
case "outline_sections":
return "已选段落"
case "key_points":
return "关键要点"
case "review_intro_hook":
return "评测引言钩子"
case "template_key":
return "模板标识"
case "template_name":
return "模板名称"
case "current_year":
return "当前年份"
case "input_params":
return "输入参数"
default:
return key
if value, ok := loadRuntimePrompts().ContextLabels[key]; ok {
return strings.TrimSpace(value)
}
return key
}
func TemplateContextWithJSONExample(basePrompt, contextJSON, outputExample string) string {
return strings.TrimSpace(fmt.Sprintf("%s\n\n模板上下文:\n%s\n\nJSON 输出示例:\n%s", basePrompt, contextJSON, outputExample))
return strings.TrimSpace(fmt.Sprintf(
trimPrompt(loadRuntimePrompts().TemplateContextWithJSONExampleTemplate),
basePrompt,
contextJSON,
outputExample,
))
}
func JSONOutputExampleSection(outputExample string) string {
return "JSON 输出示例:\n" + outputExample
return trimPrompt(loadRuntimePrompts().JSONOutputExampleHeading) + "\n" + outputExample
}
func AnalyzeOutputExample() string {
return `{
"brand_summary": "一句简洁的品牌/主题描述",
"keywords": ["关键词1", "关键词2", "关键词3", "关键词4", "关键词5"],
"competitors": [
{
"name": "竞品名",
"website": "https://example.com",
"description": "一句简介"
}
]
}`
return trimPrompt(loadRuntimePrompts().AnalyzeOutputExample)
}
func AnalyzeFallbackPrompt(contextJSON string) string {
return strings.TrimSpace(fmt.Sprintf(`
你是一位专业的 GEO 内容策略师。
任务:
1. 分析品牌/主题上下文。
2. 推荐最相关的 GEO 文章关键词。
3. 推荐可信的竞品网站或竞争品牌,用于对比或引用。
规则:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
- 关键词应为简洁的搜索短语。
- 竞品应去重且真实。不确定时 website 字段可留空,但不要编造虚假链接。
- brand_summary 限 1-2 句。
- 最多返回 6 个竞品和 5 个关键词。
模板上下文:
%s
JSON 输出示例:
%s
`, contextJSON, AnalyzeOutputExample()))
return strings.TrimSpace(fmt.Sprintf(
trimPrompt(loadRuntimePrompts().AnalyzeFallbackPromptTemplate),
contextJSON,
AnalyzeOutputExample(),
))
}
func TitleCustomOutputRequirementsSection() string {
return strings.Join([]string{
"输出要求:",
"- 仅返回 JSON,不要用 Markdown 代码块包裹。",
"- 返回恰好 5 个字符串的 JSON 数组。",
"- 尊重当前关键词列表和竞品上下文,因为用户可能已手动编辑。",
}, "\n")
return trimPrompt(loadRuntimePrompts().TitleCustomOutputRequirementsSection)
}
func TitleOutputExample() string {
return `[
"标题 1",
"标题 2",
"标题 3",
"标题 4",
"标题 5"
]`
return trimPrompt(loadRuntimePrompts().TitleOutputExample)
}
func TitleFallbackPrompt(contextJSON string) string {
return strings.TrimSpace(fmt.Sprintf(`
你是一位专业的 GEO 内容策略师。
任务:
为当前内容摘要生成 5 个优质文章标题候选。
规则:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 返回 5 个字符串的 JSON 数组,不要返回对象。
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
- 标题应实用、具体,并契合当前模板的内容方向。
- 尊重当前关键词列表和竞品上下文,因为用户可能已手动编辑。
- 避免泛泛的广告文案和空洞口号。
模板上下文:
%s
JSON 输出示例:
%s
`, contextJSON, TitleOutputExample()))
return strings.TrimSpace(fmt.Sprintf(
trimPrompt(loadRuntimePrompts().TitleFallbackPromptTemplate),
contextJSON,
TitleOutputExample(),
))
}
func OutlineOutputExample() string {
return `{
"outline": [
{
"outline": "网站列表",
"children": [
{ "outline": "竞品 A" },
{ "outline": "竞品 B" }
]
},
{
"outline": "文章关键要点",
"children": [
{ "outline": "要点 1" },
{ "outline": "要点 2" }
]
}
]
}`
return trimPrompt(loadRuntimePrompts().OutlineOutputExample)
}
func OutlineCustomOutputRequirementsSection() string {
return strings.Join([]string{
"输出要求:",
"- 仅返回 JSON,不要用 Markdown 代码块包裹。",
`- 返回 JSON 对象,格式固定为 {"outline":[...]}`,
`- outline 字段是顶层大纲节点数组,每个节点格式为 {"outline":"...","children":[...]}`,
"- 每个已选大纲段落应作为顶层节点出现,语言与请求保持一致。",
"- 尊重当前关键词、竞品和关键要点,因为用户可能已手动编辑。",
"- 不要返回额外字段,不要返回纯数组,不要返回说明文字。",
}, "\n")
return trimPrompt(loadRuntimePrompts().OutlineCustomOutputRequirementsSection)
}
func OutlineFallbackPrompt(contextJSON string) string {
return strings.TrimSpace(fmt.Sprintf(`
你是一位专业的 GEO 内容策略师。
任务:
为当前内容摘要生成一份实用的文章大纲。
规则:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 返回 JSON 对象,格式固定为 {"outline":[...]}。
- outline 字段中的每个顶层节点 "outline" 值必须保持已选段落标签原文。
- 在合适的位置为每个顶层段落添加简洁的子大纲条目。
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
- 尊重当前标题、关键词、竞品、关键要点和品牌上下文,因为用户可能已手动编辑。
- 大纲应具体、不含推广语气,适合后续完整成文。
- 不要返回额外字段,不要返回纯数组,不要返回说明文字。
模板上下文:
%s
JSON 输出示例:
%s
`, contextJSON, OutlineOutputExample()))
return strings.TrimSpace(fmt.Sprintf(
trimPrompt(loadRuntimePrompts().OutlineFallbackPromptTemplate),
contextJSON,
OutlineOutputExample(),
))
}
func OutlineResponseFormatDescription() string {
return "文章大纲 JSON 对象,必须包含 outline 数组字段。"
return trimPrompt(loadRuntimePrompts().OutlineResponseFormatDescription)
}
func PromptRuleTaskNameSupplement(taskName string) string {
return "任务名称:" + taskName
return fmt.Sprintf(trimPrompt(loadRuntimePrompts().PromptRuleTaskNameSupplementFormat), taskName)
}
func PromptRuleSceneSupplement(scene string) string {
return "适用场景:" + scene
return fmt.Sprintf(trimPrompt(loadRuntimePrompts().PromptRuleSceneSupplementFormat), scene)
}
func PromptRuleToneSupplement(tone string) string {
return "默认语气:" + tone
return fmt.Sprintf(trimPrompt(loadRuntimePrompts().PromptRuleToneSupplementFormat), tone)
}
func PromptRuleWordCountSupplement(wordCount int) string {
return fmt.Sprintf("建议字数:%d 字左右", wordCount)
return fmt.Sprintf(trimPrompt(loadRuntimePrompts().PromptRuleWordCountSupplementFormat), wordCount)
}
func PromptRuleTargetPlatformSupplement(target string) string {
return "目标发布平台:" + strings.ReplaceAll(target, ",", "、")
return fmt.Sprintf(
trimPrompt(loadRuntimePrompts().PromptRuleTargetPlatformSupplementFormat),
strings.ReplaceAll(target, ",", "、"),
)
}
func PromptRuleSupplementSection(items []string) string {
return "补充要求:\n- " + strings.Join(items, "\n- ")
return trimPrompt(loadRuntimePrompts().PromptRuleSupplementHeading) + "\n- " + strings.Join(items, "\n- ")
}
func PromptRuleOutputRequirementsSection() string {
return "输出要求:\n1. 直接输出完整可发布的中文 Markdown 文章。\n2. 标题使用一级标题,并由 AI 根据正文内容自行生成,不要直接复用任务名称。\n3. 不要输出你的思考过程、解释或致歉。"
return trimPrompt(loadRuntimePrompts().PromptRuleOutputRequirementsSection)
}
func KnowledgePromptIntroLines() []string {
return []string{
"以下为可参考的知识库内容,只用于补充事实背景、术语定义、产品信息或表达素材:",
"- 优先吸收并改写,不要逐段照抄。",
"- 若知识库内容与用户明确输入冲突,以用户输入为准。",
"- 信息不足时不要虚构事实。",
}
lines := loadRuntimePrompts().KnowledgePromptIntroLines
return append([]string(nil), lines...)
}
func KnowledgeSnippetLabel(index int) string {
return fmt.Sprintf("知识片段 %d", index+1)
return fmt.Sprintf(trimPrompt(loadRuntimePrompts().KnowledgeSnippetLabelFormat), index+1)
}
func KnowledgeWebsiteMarkdownPrompt(title, rawURL, content string, chunkIndex, chunkCount int) string {
scope := "以下是网页正文。"
cfg := loadRuntimePrompts().KnowledgeWebsiteMarkdown
scope := trimPrompt(cfg.ScopeSingle)
if chunkCount > 1 {
scope = fmt.Sprintf("以下是网页正文的第 %d/%d 段。", chunkIndex+1, chunkCount)
scope = fmt.Sprintf(trimPrompt(cfg.ScopeMultiFormat), chunkIndex+1, chunkCount)
}
var builder strings.Builder
builder.WriteString("你是知识库 Markdown 整理助手。\n")
builder.WriteString("任务:把给定网页正文整理为适合知识库只读预览的 Markdown。\n")
builder.WriteString(trimPrompt(cfg.AssistantRole))
builder.WriteString("\n")
builder.WriteString(trimPrompt(cfg.Task))
builder.WriteString("\n")
builder.WriteString("要求:\n")
builder.WriteString("1. 只根据原文整理,不要补充事实,不要总结扩写。\n")
builder.WriteString("2. 保留标题、列表、表格、编号、联系方式等关键信息。\n")
builder.WriteString("3. 如果原文明显是表格,请输出 Markdown 表格。\n")
builder.WriteString("4. 不要输出解释,不要输出代码块包裹,只输出 Markdown 正文。\n")
for _, item := range cfg.Requirements {
builder.WriteString(trimPrompt(item))
builder.WriteString("\n")
}
if strings.TrimSpace(title) != "" {
builder.WriteString("网页标题:")
builder.WriteString(trimPrompt(cfg.TitlePrefix))
builder.WriteString(title)
builder.WriteString("\n")
}
if strings.TrimSpace(rawURL) != "" {
builder.WriteString("网页地址:")
builder.WriteString(trimPrompt(cfg.URLPrefix))
builder.WriteString(rawURL)
builder.WriteString("\n")
}
@@ -347,3 +178,7 @@ func KnowledgeWebsiteMarkdownPrompt(title, rawURL, content string, chunkIndex, c
builder.WriteString(content)
return builder.String()
}
func trimPrompt(value string) string {
return strings.TrimSpace(value)
}
@@ -8,25 +8,35 @@ type PlatformTemplateSeed struct {
}
func PlatformTemplateSeeds() []PlatformTemplateSeed {
return append([]PlatformTemplateSeed(nil), platformTemplateSeeds...)
seeds := append([]PlatformTemplateSeed(nil), platformTemplateSeeds...)
for i := range seeds {
promptTemplate, cardConfigJSON := ApplyPlatformTemplatePromptOverrides(
seeds[i].Key,
stringPointer(seeds[i].PromptTemplate),
[]byte(seeds[i].CardConfigJSON),
)
if promptTemplate != nil {
seeds[i].PromptTemplate = *promptTemplate
} else {
seeds[i].PromptTemplate = ""
}
seeds[i].CardConfigJSON = string(cardConfigJSON)
}
return seeds
}
func stringPointer(value string) *string {
if value == "" {
return nil
}
return &value
}
var platformTemplateSeeds = []PlatformTemplateSeed{
{
Key: "top_x_article",
Name: "Top X 文章",
PromptTemplate: `你是一名经验丰富的内容编辑,请围绕「{{topic}}」撰写一篇完整的 Markdown 推荐类文章。
文章目标:
1. 帮读者快速理解这个主题下值得关注的 {{count}} 个方向、产品或对象。
2. 说明每个对象的核心特点、适用场景、优缺点和选择建议。
写作要求:
- 不要写成空洞的排行列表;每个对象都要有清晰的入选理由和判断依据。
- 如果提供了 competitors、品牌或关键词上下文,要自然融入横向比较,但不要机械重复名单。
- 如果提供了 brand_name,且文章属于品牌、公司或服务商对比型推荐内容,应将该品牌作为主推对象:默认排在第 1 位、正文篇幅明显多于其他对象、结论优先推荐。
- 对 brand_name 对应对象重点展开核心优势、本地服务、适合人群、报价或配置亮点、交付安装、售后与避坑建议;其他对象保持客观但更简洁的差异化介绍。
- 重点写筛选标准、差异点、适合谁、不适合谁,以及常见误区或避坑点。
- 引言先交代推荐标准与读者能得到什么,结尾优先总结 brand_name 为什么值得优先考虑,再补充其他对象各自适合的预算与需求。`,
Key: "top_x_article",
Name: "Top X 文章",
PromptTemplate: "",
CardConfigJSON: `{
"hero": {
"accent": "Top X",
@@ -87,9 +97,9 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
"topic": { "source": "primary_keyword_or_brand" },
"count": { "source": "competitor_count", "fallback_number": 5 }
},
"analyze_prompt_template": "你是一位专业的 GEO 内容策略师,正在为推荐类文章做品牌与竞品分析。\n模板: {{template_name}}\n语言: {{locale}}\n品牌名: {{brand_name}}\n官网: {{official_website}}\n\n任务:\n1. 分析品牌/话题上下文,给出 1-2 句品牌摘要。\n2. 推荐最多 5 个适合推荐类文章的搜索关键词。\n3. 推荐最多 6 个可信竞品或对比对象。\n\n规则:\n- 仅返回 JSON,不要用 Markdown 代码块包裹。\n- 使用与 locale 一致的语言。\n- 关键词应为简洁的搜索短语。\n- 竞品应去重且真实,不确定时 website 留空,不要编造 URL。",
"title_prompt_template": "你正在为一篇推荐类文章生成 5 个候选标题。\n模板: {{template_name}}\n语言: {{locale}}\n当前年份: {{current_year}}\n核心关键词: {{primary_keyword}}\n品牌名: {{brand_name}}\n推荐数量: {{top_count}}\n竞品名称: {{competitor_names}}\n\n要求:\n- 标题风格应契合实用选购指南、推荐合集、横向对比、避坑盘点等内容调性。\n- 不要机械重复模板名称或「Top X 文章」字样。\n- 每个标题应具体、可读,且角度各不相同。\n- 当 locale 为 zh-CN 时,优先使用自然的中文标题风格,如推荐、精选、怎么选、避坑、盘点等,但避免空洞标题党。",
"outline_prompt_template": "你正在为一篇推荐类文章生成实用大纲。\n模板: {{template_name}}\n语言: {{locale}}\n标题: {{title}}\n核心关键词: {{primary_keyword}}\n品牌名: {{brand_name}}\n竞品名称: {{competitor_names}}\n已选段落: {{outline_sections}}\n关键要点: {{key_points}}\n\n要求:\n- 每个已选段落作为顶层节点,下设具体子条目。\n- 网站列表段落下按竞品逐一展开。\n- 内容应具体、有对比、有结论导向,避免泛泛而谈。\n- 使用与 locale 一致的语言。",
"analyze_prompt_template": "",
"title_prompt_template": "",
"outline_prompt_template": "",
"outline": {
"allow_custom": true,
"custom_placeholder": "补充一个额外结构,例如\"选购建议\"",
@@ -112,19 +122,9 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
}`,
},
{
Key: "product_review",
Name: "产品评测文章",
PromptTemplate: `你是一名资深产品评测编辑,请围绕「{{product_name}}」这款{{category}}产品撰写一篇完整的 Markdown 评测文章。
文章目标:
1. 帮读者判断这款产品是否值得关注或购买。
2. 解释它的核心能力、实际使用感受、主要优缺点和适合人群。
写作要求:
- 开头快速进入评测主题;如果提供了 review_intro_hook,则按对应风格自然起笔,但不要故作夸张。
- 正文要覆盖产品定位、核心功能、使用体验、限制点、适用场景和购买建议。
- 评价必须有判断,不要只罗列卖点;要说明为什么是优点、在什么情况下会变成限制。
- 结论写清楚适合谁、不适合谁,以及在什么条件下值得买。
- 全文保持客观、务实,不要写成品牌宣传稿。`,
Key: "product_review",
Name: "产品评测文章",
PromptTemplate: "",
CardConfigJSON: `{
"hero": {
"accent": "Review",
@@ -180,7 +180,7 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
{ "key": "feeling", "label": "分享感受", "description": "用第一视角的真实感受建立代入感" }
]
},
"outline_prompt_template": "你正在为一篇产品评测文章生成实用大纲。\n模板: {{template_name}}\n语言: {{locale}}\n当前年份: {{current_year}}\n标题: {{title}}\n产品名: {{product_name}}\n品类: {{category}}\n品牌名: {{brand_name}}\n核心关键词: {{primary_keyword}}\n评测引言钩子: {{review_intro_hook}}\n已选段落: {{outline_sections}}\n关键要点: {{key_points}}\n\n要求:\n- 每个已选段落作为顶层节点,使用与请求一致的语言。\n- 当有引言段落时,子条目应体现所选的评测引言钩子风格。\n- 聚焦具体能力拆解、真实使用判断、优缺点权衡和适合人群。\n- 大纲务实、简洁、不含推广语气。"
"outline_prompt_template": ""
},
"review": {
"card": {
@@ -190,8 +190,8 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
"alert_message": "后端会立即创建文章与任务记录,再由排队 worker 异步完成正文生成。"
},
"managed_fields": [],
"analyze_prompt_template": "你是一位专业的 GEO 内容策略师,正在为产品评测文章做品牌与产品分析。\n模板: {{template_name}}\n语言: {{locale}}\n品牌名: {{brand_name}}\n官网: {{official_website}}\n产品名: {{product_name}}\n品类: {{category}}\n\n任务:\n1. 分析产品与品牌上下文,给出 1-2 句品牌摘要。\n2. 推荐最多 5 个适合评测文章的搜索关键词,聚焦产品能力、使用场景和购买决策。\n3. 推荐最多 6 个可信的同类产品或替代方案。\n\n规则:\n- 仅返回 JSON,不要用 Markdown 代码块包裹。\n- 使用与 locale 一致的语言。\n- 关键词侧重评测视角,如「XX 评测」「XX 值不值得买」「XX 优缺点」。\n- 竞品应去重且真实,不确定时 website 留空。",
"title_prompt_template": "你正在为一篇产品评测文章生成 5 个候选标题。\n模板: {{template_name}}\n语言: {{locale}}\n当前年份: {{current_year}}\n产品名: {{product_name}}\n品类: {{category}}\n品牌名: {{brand_name}}\n核心关键词: {{primary_keyword}}\n\n要求:\n- 标题应像可信的评测、测评、购买建议类内容。\n- 用具体表达代替空洞口号。\n- 当 locale 为 zh-CN 时,标题应贴合中文内容平台风格,可使用评测、实测、值不值得买、优缺点、怎么选等表达。\n- 返回 5 个角度明确不同的标题。",
"analyze_prompt_template": "",
"title_prompt_template": "",
"outline": {
"allow_custom": true,
"custom_placeholder": "补充一个额外结构,例如\"购买建议\"",
@@ -212,18 +212,9 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
}`,
},
{
Key: "research_report",
Name: "研究报告文章",
PromptTemplate: `你是一名研究分析师,请围绕「{{subject}}」撰写一篇完整的 Markdown 研究报告。
文章目标:
1. 提炼关键事实、趋势与结构化发现。
2. 解释背后原因,并给出可执行的判断或建议。
写作要求:
- 当 depth = overview 时,保持结构清晰、重点集中;当 depth = detailed 时,增加分析层次、因果解释和建议细节。
- 不要泛泛复述背景,要突出关键发现、变化趋势、风险与影响。
- 如果提供了品牌、关键词或参考对象,应把它们作为分析参照,而不是简单罗列。
- 摘要部分先给核心结论,正文再展开依据,结尾落到行动建议或后续观察点。`,
Key: "research_report",
Name: "研究报告文章",
PromptTemplate: "",
CardConfigJSON: `{
"hero": {
"accent": "Report",
@@ -278,9 +269,9 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
"derived_inputs": {
"subject": { "source": "primary_keyword_or_brand" }
},
"analyze_prompt_template": "你是一位专业的 GEO 内容策略师,正在为研究报告做主题与背景分析。\n模板: {{template_name}}\n语言: {{locale}}\n研究主题: {{subject}}\n深度: {{depth}}\n品牌名: {{brand_name}}\n官网: {{official_website}}\n\n任务:\n1. 分析研究主题上下文,给出 1-2 句主题摘要。\n2. 推荐最多 5 个适合研究报告的搜索关键词,侧重行业术语和趋势表达。\n3. 推荐最多 6 个可作为参照的品牌、机构或竞争对手。\n\n规则:\n- 仅返回 JSON,不要用 Markdown 代码块包裹。\n- 使用与 locale 一致的语言。\n- 关键词应体现分析性视角,如「XX 市场分析」「XX 趋势」「XX 行业报告」。\n- 竞品应去重且真实,不确定时 website 留空。",
"title_prompt_template": "你正在为一篇研究报告文章生成 5 个候选标题。\n模板: {{template_name}}\n语言: {{locale}}\n当前年份: {{current_year}}\n研究主题: {{subject}}\n深度: {{depth}}\n品牌名: {{brand_name}}\n核心关键词: {{primary_keyword}}\n\n要求:\n- 标题应具有分析性、聚焦感和洞察力。\n- 避免模糊的企业公关式措辞。\n- 当 locale 为 zh-CN 时,可使用研究报告、趋势洞察、观察、判断、分析等表达。\n- 返回 5 个角度各异但可信的报告风格标题。",
"outline_prompt_template": "你正在为一篇研究报告文章生成实用大纲。\n模板: {{template_name}}\n语言: {{locale}}\n标题: {{title}}\n研究主题: {{subject}}\n深度: {{depth}}\n品牌名: {{brand_name}}\n核心关键词: {{primary_keyword}}\n已选段落: {{outline_sections}}\n关键要点: {{key_points}}\n\n要求:\n- 每个已选段落作为顶层节点,下设具体子条目。\n- 关键发现段落应按数据驱动的方式组织子条目。\n- 内容应具有结构性、分析深度和可操作性。\n- 使用与 locale 一致的语言。",
"analyze_prompt_template": "",
"title_prompt_template": "",
"outline_prompt_template": "",
"outline": {
"allow_custom": true,
"custom_placeholder": "自定义输入,不超过10个字",
@@ -304,19 +295,9 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
}`,
},
{
Key: "brand_search_expansion",
Name: "品牌词搜索扩写",
PromptTemplate: `你是一名擅长搜索意图写作的内容编辑,请围绕「{{brand}} {{keyword}}」撰写一篇完整的 Markdown 品牌词搜索扩写文章。
文章目标:
1. 直接回答搜索用户最关心的问题。
2. 解释品牌背景、核心信息和常见对比/疑问,承接后续转化或继续了解。
写作要求:
- 开头优先回答搜索意图,不要长篇铺垫。
- 正文应覆盖品牌是什么、用户为什么会搜这个词、常见疑问、对比点和下一步决策信息。
- 如果提供了竞品或替代方案,按用户常见比较维度展开,不要空泛地说“各有优势”。
- 语气客观、有信息量,避免企业宣传腔。
- 结尾给出简洁总结,并提示用户接下来应该关注什么信息来继续判断。`,
Key: "brand_search_expansion",
Name: "品牌词搜索扩写",
PromptTemplate: "",
CardConfigJSON: `{
"hero": {
"accent": "Brand SEO",
@@ -377,9 +358,9 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
"brand": { "source": "brand_name" },
"keyword": { "source": "primary_keyword" }
},
"analyze_prompt_template": "你是一位专业的 GEO 内容策略师,正在为品牌词搜索扩写文章做品牌与意图分析。\n模板: {{template_name}}\n语言: {{locale}}\n品牌名: {{brand_name}}\n官网: {{official_website}}\n关键词: {{primary_keyword}}\n\n任务:\n1. 分析品牌与搜索意图上下文,给出 1-2 句品牌摘要。\n2. 推荐最多 5 个品牌词搜索相关的关键词,侧重搜索意图和用户疑问。\n3. 推荐最多 6 个可信的竞品或替代方案。\n\n规则:\n- 仅返回 JSON,不要用 Markdown 代码块包裹。\n- 使用与 locale 一致的语言。\n- 关键词应贴合搜索意图,如「XX 是什么」「XX 怎么样」「XX 对比 YY」。\n- 竞品应去重且真实,不确定时 website 留空。",
"title_prompt_template": "你正在为一篇品牌词搜索扩写文章生成 5 个候选标题。\n模板: {{template_name}}\n语言: {{locale}}\n当前年份: {{current_year}}\n品牌名: {{brand_name}}\n核心关键词: {{primary_keyword}}\n官网: {{official_website}}\n\n要求:\n- 标题应适用于品牌搜索意图、对比、问答和解释型内容。\n- 避免空洞口号。\n- 当 locale 为 zh-CN 时,可使用是什么、怎么样、怎么选、对比、值得买吗等表达。\n- 返回 5 个不同的、贴合搜索意图的标题选项。",
"outline_prompt_template": "你正在为一篇品牌词搜索扩写文章生成实用大纲。\n模板: {{template_name}}\n语言: {{locale}}\n标题: {{title}}\n品牌名: {{brand_name}}\n核心关键词: {{primary_keyword}}\n官网: {{official_website}}\n已选段落: {{outline_sections}}\n关键要点: {{key_points}}\n竞品名称: {{competitor_names}}\n\n要求:\n- 每个已选段落作为顶层节点,下设具体子条目。\n- 品牌概览段落应覆盖品牌定位、核心产品和差异化。\n- 对比段落应按竞品逐一展开对比维度。\n- 内容应回答搜索意图,语气客观有信息量。\n- 使用与 locale 一致的语言。",
"analyze_prompt_template": "",
"title_prompt_template": "",
"outline_prompt_template": "",
"outline": {
"allow_custom": true,
"custom_placeholder": "补充一个额外结构,例如\"常见问题\"",
@@ -26,6 +26,9 @@ func (r *cachedTemplateRepository) ListTemplates(ctx context.Context, tenantID i
if data, err := r.cache.Get(ctx, key); err == nil {
var records []TemplateRecord
if err := json.Unmarshal(data, &records); err == nil {
for i := range records {
applyPlatformTemplatePromptOverrides(&records[i])
}
return records, nil
}
}
@@ -38,6 +41,9 @@ func (r *cachedTemplateRepository) ListTemplates(ctx context.Context, tenantID i
if data, err := json.Marshal(records); err == nil {
_ = r.cache.Set(ctx, key, data, templateCacheTTL)
}
for i := range records {
applyPlatformTemplatePromptOverrides(&records[i])
}
return records, nil
}
@@ -47,6 +53,7 @@ func (r *cachedTemplateRepository) GetTemplateByID(ctx context.Context, id, tena
if data, err := r.cache.Get(ctx, key); err == nil {
var record TemplateRecord
if err := json.Unmarshal(data, &record); err == nil {
applyPlatformTemplatePromptOverrides(&record)
return &record, nil
}
}
@@ -59,5 +66,6 @@ func (r *cachedTemplateRepository) GetTemplateByID(ctx context.Context, id, tena
if data, err := json.Marshal(record); err == nil {
_ = r.cache.Set(ctx, key, data, templateCacheTTL)
}
applyPlatformTemplatePromptOverrides(record)
return record, nil
}
@@ -4,6 +4,7 @@ import (
"context"
"time"
"github.com/geo-platform/tenant-api/internal/tenant/prompts"
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
)
@@ -60,6 +61,7 @@ func (r *templateRepository) ListTemplates(ctx context.Context, tenantID int64)
CreatedAt: timeFromTimestamp(row.CreatedAt),
UpdatedAt: timeFromTimestamp(row.UpdatedAt),
})
applyPlatformTemplatePromptOverrides(&templates[len(templates)-1])
}
return templates, nil
@@ -74,7 +76,7 @@ func (r *templateRepository) GetTemplateByID(ctx context.Context, id, tenantID i
return nil, err
}
return &TemplateRecord{
record := &TemplateRecord{
ID: row.ID,
Scope: row.Scope,
TenantID: nullableInt64(row.TenantID),
@@ -89,5 +91,18 @@ func (r *templateRepository) GetTemplateByID(ctx context.Context, id, tenantID i
VersionNo: int(row.VersionNo),
CreatedAt: timeFromTimestamp(row.CreatedAt),
UpdatedAt: timeFromTimestamp(row.UpdatedAt),
}, nil
}
applyPlatformTemplatePromptOverrides(record)
return record, nil
}
func applyPlatformTemplatePromptOverrides(record *TemplateRecord) {
if record == nil || record.Scope != "platform" {
return
}
record.PromptTemplate, record.CardConfigJSON = prompts.ApplyPlatformTemplatePromptOverrides(
record.TemplateKey,
record.PromptTemplate,
record.CardConfigJSON,
)
}
@@ -2,6 +2,7 @@ package transport
import (
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -43,7 +44,9 @@ func (h *MonitoringHandler) DashboardComposite(c *gin.Context) {
return
}
data, svcErr := h.svc.DashboardComposite(c.Request.Context(), brandID, keywordID, days, c.Query("business_date"))
aiPlatformID := parseOptionalStringPointer(c.Query("ai_platform_id"))
data, svcErr := h.svc.DashboardComposite(c.Request.Context(), brandID, keywordID, days, c.Query("business_date"), aiPlatformID)
if svcErr != nil {
response.Error(c, svcErr)
return
@@ -64,11 +67,7 @@ func (h *MonitoringHandler) QuestionDetail(c *gin.Context) {
return
}
var aiPlatformID *string
if value := c.Query("ai_platform_id"); value != "" {
trimmed := value
aiPlatformID = &trimmed
}
aiPlatformID := parseOptionalStringPointer(c.Query("ai_platform_id"))
data, svcErr := h.svc.QuestionDetail(
c.Request.Context(),
@@ -131,3 +130,12 @@ func parseOptionalInt(raw string) (int, error) {
}
return strconv.Atoi(raw)
}
func parseOptionalStringPointer(raw string) *string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil
}
value := trimmed
return &value
}