feat(monitoring): attribute SaaS citations by registrable domain
Persist host/registrable_domain/site_key on monitoring article URL aliases, populate them on desktop-publish completion, and use them in the citation pipeline to count how often SaaS-published content appears as a citation source. Expose a /citation-summary endpoint with a 7/30-day window switch and surface the new source-share metrics in the tracking dashboard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -432,7 +432,10 @@ const enUS = {
|
||||
backToQuestions: "Back to questions",
|
||||
citationRanking: "Citation Ranking",
|
||||
citationRankingTitle: "Citation Ranking",
|
||||
citationRankingHint: "Track how often content published from this system appears across model answers.",
|
||||
citationRankingHint: "Attribute SaaS-published content by registrable domain across model citation sources.",
|
||||
citationWindow7: "Last 7 days",
|
||||
citationWindow30: "Last 30 days",
|
||||
citationWindowLabel: "Last {days} days",
|
||||
citedArticles: "Cited Articles",
|
||||
citedArticlesTitle: "Cited Articles",
|
||||
answerContent: "Answer Content",
|
||||
@@ -492,6 +495,7 @@ const enUS = {
|
||||
citedAnswers: "Cited Answers",
|
||||
citedArticles: "Cited Articles",
|
||||
citationRate: "Citation Rate",
|
||||
saasSourceRate: "Source Share",
|
||||
citations: "Citations",
|
||||
sourcePlatform: "Source",
|
||||
channelName: "Channel",
|
||||
|
||||
@@ -433,7 +433,10 @@ const zhCN = {
|
||||
backToQuestions: "返回问题列表",
|
||||
citationRanking: "Citation Ranking",
|
||||
citationRankingTitle: "引用排行",
|
||||
citationRankingHint: "统计系统发布内容在各模型回答中出现的次数与覆盖率。",
|
||||
citationRankingHint: "按主体域名归因 SaaS 发文在模型引用来源中的出现次数与占比。",
|
||||
citationWindow7: "近 7 天",
|
||||
citationWindow30: "近 30 天",
|
||||
citationWindowLabel: "近 {days} 天",
|
||||
citedArticles: "Cited Articles",
|
||||
citedArticlesTitle: "被引用文章",
|
||||
answerContent: "答案内容",
|
||||
@@ -493,6 +496,7 @@ const zhCN = {
|
||||
citedAnswers: "引用回答数",
|
||||
citedArticles: "引用文章数",
|
||||
citationRate: "引用率",
|
||||
saasSourceRate: "来源占比",
|
||||
citations: "引用次数",
|
||||
sourcePlatform: "来源平台",
|
||||
channelName: "渠道名称",
|
||||
|
||||
@@ -57,6 +57,7 @@ import type {
|
||||
LoginResponse,
|
||||
MediaPlatform,
|
||||
MonitoringCollectNowResponse,
|
||||
MonitoringCitationSummaryResponse,
|
||||
MonitoringDashboardCompositeResponse,
|
||||
MonitoringQuestionDetailResponse,
|
||||
PlatformAccount,
|
||||
@@ -1075,6 +1076,13 @@ export const monitoringApi = {
|
||||
params,
|
||||
});
|
||||
},
|
||||
citationSummary(params: {
|
||||
days?: number;
|
||||
}) {
|
||||
return apiClient.get<MonitoringCitationSummaryResponse>("/api/tenant/monitoring/citation-summary", {
|
||||
params,
|
||||
});
|
||||
},
|
||||
questionDetail(
|
||||
brandId: number,
|
||||
questionId: number,
|
||||
|
||||
@@ -37,6 +37,9 @@ type CitationRankingRow = {
|
||||
platform_name: string;
|
||||
cited_answer_count: number | null;
|
||||
cited_article_count: number | null;
|
||||
citation_source_count: number | null;
|
||||
saas_source_count: number | null;
|
||||
saas_source_rate: number | null;
|
||||
citation_rate: number | null;
|
||||
share: number;
|
||||
color: string;
|
||||
@@ -95,9 +98,11 @@ const selectedBrandId = useStorage<number | null>("tracking_selected_brand", nul
|
||||
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 selectedCitationWindowDays = useStorage<number>("tracking_selected_citation_window_days", 7);
|
||||
|
||||
selectedPlatformId.value = normalizeMonitoringPlatformFilter(selectedPlatformId.value);
|
||||
selectedBusinessDate.value = normalizeTrackingBusinessDate(selectedBusinessDate.value) ?? trackingToday;
|
||||
selectedCitationWindowDays.value = normalizeCitationWindowDays(selectedCitationWindowDays.value);
|
||||
|
||||
const brandsQuery = useQuery({
|
||||
queryKey: ["tracking", "brands"],
|
||||
@@ -188,12 +193,14 @@ watch([selectedBrandId, selectedKeywordId, selectedPlatformId, selectedBusinessD
|
||||
const currentKeywordId = normalizeQueryValue(route.query.keyword_id) || undefined;
|
||||
const currentPlatformId = normalizeMonitoringPlatformFilter(route.query.ai_platform_id);
|
||||
const currentBusinessDate = normalizeTrackingBusinessDate(route.query.business_date) ?? trackingToday;
|
||||
const hasLegacyCitationDaysQuery = normalizeQueryValue(route.query.citation_days) !== "";
|
||||
|
||||
if (
|
||||
currentBrandId === nextBrandId &&
|
||||
currentKeywordId === nextKeywordId &&
|
||||
currentPlatformId === (nextPlatformId ?? "all") &&
|
||||
currentBusinessDate === nextBusinessDate
|
||||
currentBusinessDate === nextBusinessDate &&
|
||||
!hasLegacyCitationDaysQuery
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -240,6 +247,18 @@ const dashboardQuery = useQuery({
|
||||
}),
|
||||
});
|
||||
|
||||
const citationSummaryQuery = useQuery({
|
||||
queryKey: computed(() => [
|
||||
"tracking",
|
||||
"citation-summary",
|
||||
selectedCitationWindowDays.value,
|
||||
]),
|
||||
queryFn: () =>
|
||||
monitoringApi.citationSummary({
|
||||
days: selectedCitationWindowDays.value,
|
||||
}),
|
||||
});
|
||||
|
||||
const aiAccountsQuery = useQuery({
|
||||
queryKey: ["tracking", "ai-platform-accounts"],
|
||||
queryFn: () => tenantAccountsApi.list(),
|
||||
@@ -405,34 +424,37 @@ const selectedMetricCards = computed(() => {
|
||||
];
|
||||
});
|
||||
|
||||
const citationRankingRows = computed<CitationRankingRow[]>(() => {
|
||||
if (!selectedDateHasData.value) {
|
||||
return [];
|
||||
}
|
||||
const citationWindowOptions = computed(() => [
|
||||
{ label: t("tracking.citationWindow7"), value: 7 },
|
||||
{ label: t("tracking.citationWindow30"), value: 30 },
|
||||
]);
|
||||
|
||||
const breakdown = dashboardQuery.data.value?.platform_breakdown ?? [];
|
||||
const ranking = dashboardQuery.data.value?.citation_ranking ?? [];
|
||||
const citationRankingRows = computed<CitationRankingRow[]>(() => {
|
||||
const ranking = citationSummaryQuery.data.value?.citation_ranking ?? [];
|
||||
const order = new Map<string, number>();
|
||||
const rows = new Map<string, CitationRankingRow>();
|
||||
|
||||
breakdown.forEach((item, index) => {
|
||||
const platformId = normalizeMonitoringPlatformId(item.ai_platform_id) || item.ai_platform_id;
|
||||
aiPlatformCatalog.forEach((item, index) => {
|
||||
const platformId = normalizeMonitoringPlatformId(item.id) || item.id;
|
||||
order.set(platformId, index);
|
||||
rows.set(platformId, {
|
||||
ai_platform_id: platformId,
|
||||
platform_name: item.platform_name,
|
||||
platform_name: item.label,
|
||||
cited_answer_count: null,
|
||||
cited_article_count: null,
|
||||
citation_source_count: null,
|
||||
saas_source_count: null,
|
||||
saas_source_rate: null,
|
||||
citation_rate: null,
|
||||
share: 0,
|
||||
...platformAppearance(platformId, item.platform_name),
|
||||
...platformAppearance(platformId, item.label),
|
||||
});
|
||||
});
|
||||
|
||||
ranking.forEach((item, index) => {
|
||||
const platformId = normalizeMonitoringPlatformId(item.ai_platform_id) || item.ai_platform_id;
|
||||
if (!order.has(platformId)) {
|
||||
order.set(platformId, breakdown.length + index);
|
||||
order.set(platformId, aiPlatformCatalog.length + index);
|
||||
}
|
||||
|
||||
const existing = rows.get(platformId);
|
||||
@@ -441,6 +463,9 @@ const citationRankingRows = computed<CitationRankingRow[]>(() => {
|
||||
platform_name: item.platform_name || existing?.platform_name || platformId,
|
||||
cited_answer_count: (existing?.cited_answer_count ?? 0) + (item.cited_answer_count ?? 0),
|
||||
cited_article_count: (existing?.cited_article_count ?? 0) + (item.cited_article_count ?? 0),
|
||||
citation_source_count: (existing?.citation_source_count ?? 0) + (item.citation_source_count ?? 0),
|
||||
saas_source_count: (existing?.saas_source_count ?? 0) + (item.saas_source_count ?? 0),
|
||||
saas_source_rate: item.saas_source_rate ?? existing?.saas_source_rate ?? null,
|
||||
citation_rate: item.citation_rate ?? existing?.citation_rate ?? null,
|
||||
share: 0,
|
||||
...platformAppearance(platformId, item.platform_name || existing?.platform_name || platformId),
|
||||
@@ -448,16 +473,16 @@ const citationRankingRows = computed<CitationRankingRow[]>(() => {
|
||||
});
|
||||
|
||||
const list = Array.from(rows.values());
|
||||
const totalAppearances = list.reduce((sum, item) => sum + Math.max(item.cited_answer_count ?? 0, 0), 0);
|
||||
const totalAppearances = list.reduce((sum, item) => sum + Math.max(item.saas_source_count ?? item.cited_answer_count ?? 0, 0), 0);
|
||||
const enriched = list.map((item) => ({
|
||||
...item,
|
||||
share: totalAppearances > 0 ? Math.max(item.cited_answer_count ?? 0, 0) / totalAppearances : 0,
|
||||
share: totalAppearances > 0 ? Math.max(item.saas_source_count ?? item.cited_answer_count ?? 0, 0) / totalAppearances : 0,
|
||||
}));
|
||||
const hasCitationData = enriched.some((item) => (item.cited_answer_count ?? 0) > 0);
|
||||
const hasCitationData = enriched.some((item) => (item.saas_source_count ?? item.cited_answer_count ?? 0) > 0);
|
||||
|
||||
return enriched.sort((left, right) => {
|
||||
if (hasCitationData) {
|
||||
const countDiff = (right.cited_answer_count ?? 0) - (left.cited_answer_count ?? 0);
|
||||
const countDiff = (right.saas_source_count ?? right.cited_answer_count ?? 0) - (left.saas_source_count ?? left.cited_answer_count ?? 0);
|
||||
if (countDiff !== 0) {
|
||||
return countDiff;
|
||||
}
|
||||
@@ -467,14 +492,11 @@ const citationRankingRows = computed<CitationRankingRow[]>(() => {
|
||||
});
|
||||
|
||||
const visibleCitedArticles = computed(() => {
|
||||
if (!selectedDateHasData.value) {
|
||||
return [];
|
||||
}
|
||||
return dashboardQuery.data.value?.cited_articles ?? [];
|
||||
return citationSummaryQuery.data.value?.cited_articles ?? [];
|
||||
});
|
||||
|
||||
const citationChartStyle = computed(() => {
|
||||
const segments = citationRankingRows.value.filter((item) => (item.cited_answer_count ?? 0) > 0);
|
||||
const segments = citationRankingRows.value.filter((item) => (item.saas_source_count ?? item.cited_answer_count ?? 0) > 0);
|
||||
|
||||
if (!segments.length) {
|
||||
return {
|
||||
@@ -533,6 +555,11 @@ function formatPercent(value: number | null | undefined): string {
|
||||
return `${(value * 100).toFixed(value < 0.1 ? 1 : 0)}%`;
|
||||
}
|
||||
|
||||
function normalizeCitationWindowDays(value: unknown): number {
|
||||
const numeric = Number(value);
|
||||
return numeric === 30 ? 30 : 7;
|
||||
}
|
||||
|
||||
function accountAuthLabel(account: DesktopAccountInfo | null): string {
|
||||
if (!account) {
|
||||
return "未绑定";
|
||||
@@ -866,11 +893,27 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
</a-card>
|
||||
|
||||
<div class="tracking-grid tracking-grid--middle">
|
||||
<a-card class="tracking-panel tracking-panel--citation" :loading="dashboardQuery.isLoading.value">
|
||||
<a-card class="tracking-panel tracking-panel--citation" :loading="citationSummaryQuery.isLoading.value">
|
||||
<div class="tracking-citation-section">
|
||||
<div class="tracking-citation-header">
|
||||
<h3 class="tracking-citation-title">{{ t("tracking.citationRankingTitle") }}</h3>
|
||||
<p class="tracking-citation-desc">{{ t("tracking.citationRankingHint") }}</p>
|
||||
<div>
|
||||
<h3 class="tracking-citation-title">{{ t("tracking.citationRankingTitle") }}</h3>
|
||||
<p class="tracking-citation-desc">{{ t("tracking.citationRankingHint") }}</p>
|
||||
</div>
|
||||
<a-radio-group
|
||||
v-model:value="selectedCitationWindowDays"
|
||||
class="citation-window-switch"
|
||||
size="small"
|
||||
button-style="solid"
|
||||
>
|
||||
<a-radio-button
|
||||
v-for="item in citationWindowOptions"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="citation-ranking-layout">
|
||||
@@ -908,6 +951,7 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
<div class="citation-ranking-sheet__head">
|
||||
<span>{{ t("tracking.columns.platform") }}</span>
|
||||
<span>{{ t("tracking.columns.appearances") }}</span>
|
||||
<span>{{ t("tracking.columns.saasSourceRate") }}</span>
|
||||
<span>{{ t("tracking.columns.citationRate") }}</span>
|
||||
</div>
|
||||
|
||||
@@ -925,7 +969,11 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
</span>
|
||||
<strong>{{ item.platform_name }}</strong>
|
||||
</div>
|
||||
<span class="citation-ranking-sheet__count">{{ formatCount(item.cited_answer_count) }} {{ item.cited_answer_count != null ? '条' : '' }}</span>
|
||||
<span class="citation-ranking-sheet__count">
|
||||
{{ formatCount(item.saas_source_count ?? item.cited_answer_count) }}
|
||||
{{ item.saas_source_count != null ? t("tracking.citations") : "" }}
|
||||
</span>
|
||||
<strong class="citation-ranking-sheet__rate">{{ formatPercent(item.saas_source_rate) }}</strong>
|
||||
<strong class="citation-ranking-sheet__rate">{{ formatPercent(item.citation_rate) }}</strong>
|
||||
</div>
|
||||
|
||||
@@ -966,9 +1014,10 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
|
||||
<div class="tracking-grid tracking-grid--bottom">
|
||||
|
||||
<a-card class="tracking-panel" :loading="dashboardQuery.isLoading.value">
|
||||
<a-card class="tracking-panel" :loading="citationSummaryQuery.isLoading.value">
|
||||
<div class="tracking-panel__header">
|
||||
<h3 class="panel-title">{{ t("tracking.citedArticlesTitle") }}</h3>
|
||||
<span class="tracking-panel__helper">{{ t("tracking.citationWindowLabel", { days: selectedCitationWindowDays }) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="article-list">
|
||||
@@ -983,6 +1032,7 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
</div>
|
||||
<div class="article-list__meta">
|
||||
<span>{{ item.citation_count }} {{ t("tracking.citations") }}</span>
|
||||
<span>{{ t("tracking.columns.share") }} {{ formatPercent(item.source_share) }}</span>
|
||||
<strong>{{ formatPercent(item.citation_rate) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1152,6 +1202,10 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
}
|
||||
|
||||
.tracking-citation-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@@ -1177,6 +1231,10 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.citation-window-switch {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.tracking-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1398,10 +1456,11 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
.citation-ranking-sheet__head,
|
||||
.citation-ranking-sheet__row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.8fr) minmax(0, 0.8fr);
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(0, 0.72fr) minmax(0, 0.72fr) minmax(0, 0.72fr);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 32px;
|
||||
min-width: 560px;
|
||||
}
|
||||
|
||||
.citation-ranking-sheet__head {
|
||||
@@ -1580,9 +1639,13 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tracking-citation-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.citation-ranking-sheet__head,
|
||||
.citation-ranking-sheet__row {
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(0, 0.7fr) minmax(0, 0.7fr);
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.7fr) minmax(0, 0.7fr) minmax(0, 0.7fr);
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -1191,6 +1191,9 @@ export interface MonitoringCitationRankingItem {
|
||||
sample_status: string;
|
||||
cited_answer_count: number;
|
||||
cited_article_count: number;
|
||||
citation_source_count: number;
|
||||
saas_source_count: number;
|
||||
saas_source_rate: number | null;
|
||||
citation_rate: number | null;
|
||||
}
|
||||
|
||||
@@ -1200,6 +1203,13 @@ export interface MonitoringCitedArticle {
|
||||
publish_platform: string;
|
||||
citation_count: number;
|
||||
citation_rate: number | null;
|
||||
source_share: number | null;
|
||||
}
|
||||
|
||||
export interface MonitoringDashboardCitationWindow {
|
||||
days: number;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export type MonitoringPlatformAuthorizationStatus =
|
||||
@@ -1217,12 +1227,19 @@ export interface MonitoringDashboardRuntime {
|
||||
export interface MonitoringDashboardCompositeResponse {
|
||||
overview: MonitoringOverview;
|
||||
runtime: MonitoringDashboardRuntime;
|
||||
citation_window: MonitoringDashboardCitationWindow;
|
||||
platform_breakdown: MonitoringPlatformBreakdownItem[];
|
||||
hot_questions: MonitoringHotQuestion[];
|
||||
citation_ranking: MonitoringCitationRankingItem[];
|
||||
cited_articles: MonitoringCitedArticle[];
|
||||
}
|
||||
|
||||
export interface MonitoringCitationSummaryResponse {
|
||||
citation_window: MonitoringDashboardCitationWindow;
|
||||
citation_ranking: MonitoringCitationRankingItem[];
|
||||
cited_articles: MonitoringCitedArticle[];
|
||||
}
|
||||
|
||||
export interface MonitoringQuestionDetailCitation {
|
||||
cited_url: string;
|
||||
cited_title: string | null;
|
||||
|
||||
@@ -590,6 +590,9 @@ func (s *DesktopTaskService) Complete(ctx context.Context, client *repository.De
|
||||
|
||||
if publishOutcome != nil {
|
||||
invalidateArticleCaches(ctx, s.cache, publishOutcome.TenantID, &publishOutcome.ArticleID)
|
||||
if syncErr := s.syncMonitoringArticleAlias(ctx, publishOutcome.ArticleAlias); syncErr != nil {
|
||||
s.logWarn("monitoring article alias sync failed after publish complete", syncErr, zap.Int64("article_id", publishOutcome.ArticleID))
|
||||
}
|
||||
}
|
||||
|
||||
s.publishTaskEvent(ctx, task, "task_completed")
|
||||
@@ -733,6 +736,9 @@ func (s *DesktopTaskService) Reconcile(ctx context.Context, actor auth.Actor, de
|
||||
|
||||
if publishOutcome != nil {
|
||||
invalidateArticleCaches(ctx, s.cache, publishOutcome.TenantID, &publishOutcome.ArticleID)
|
||||
if syncErr := s.syncMonitoringArticleAlias(ctx, publishOutcome.ArticleAlias); syncErr != nil {
|
||||
s.logWarn("monitoring article alias sync failed after publish reconcile", syncErr, zap.Int64("article_id", publishOutcome.ArticleID))
|
||||
}
|
||||
}
|
||||
|
||||
eventType := "task_reconciled"
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *DesktopTaskService) syncMonitoringArticleAlias(ctx context.Context, input *monitoringArticleAliasInput) error {
|
||||
if s == nil || s.monitoringPool == nil || input == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
originalURL, confidence := monitoringAliasOriginalURL(input)
|
||||
if originalURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
normalizedURL := normalizeCitationURL(originalURL)
|
||||
if normalizedURL == "" {
|
||||
return nil
|
||||
}
|
||||
host, registrableDomain, _, _ := deriveCitationURLParts(originalURL, normalizedURL)
|
||||
if registrableDomain == "" {
|
||||
registrableDomain = host
|
||||
}
|
||||
siteKey := host
|
||||
if siteKey == "" {
|
||||
siteKey = registrableDomain
|
||||
}
|
||||
lastPathSegment := monitoringAliasLastPathSegment(normalizedURL)
|
||||
|
||||
_, err := s.monitoringPool.Exec(ctx, `
|
||||
INSERT INTO monitoring_article_url_aliases (
|
||||
tenant_id, article_id, publish_record_id, platform_id, publish_platform_name_snapshot,
|
||||
external_article_id, article_title_snapshot, original_url, normalized_url,
|
||||
host, registrable_domain, site_key, last_path_segment, confidence
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8, $9,
|
||||
$10, $11, $12, $13, $14
|
||||
)
|
||||
ON CONFLICT (tenant_id, normalized_url)
|
||||
DO UPDATE SET
|
||||
article_id = EXCLUDED.article_id,
|
||||
publish_record_id = EXCLUDED.publish_record_id,
|
||||
platform_id = EXCLUDED.platform_id,
|
||||
publish_platform_name_snapshot = EXCLUDED.publish_platform_name_snapshot,
|
||||
external_article_id = EXCLUDED.external_article_id,
|
||||
article_title_snapshot = EXCLUDED.article_title_snapshot,
|
||||
original_url = EXCLUDED.original_url,
|
||||
host = EXCLUDED.host,
|
||||
registrable_domain = EXCLUDED.registrable_domain,
|
||||
site_key = EXCLUDED.site_key,
|
||||
last_path_segment = EXCLUDED.last_path_segment,
|
||||
confidence = EXCLUDED.confidence,
|
||||
updated_at = NOW()
|
||||
`,
|
||||
input.TenantID,
|
||||
input.ArticleID,
|
||||
input.PublishRecordID,
|
||||
strings.TrimSpace(input.PlatformID),
|
||||
strings.TrimSpace(input.PlatformName),
|
||||
normalizeStringPointer(input.ExternalArticleID),
|
||||
strings.TrimSpace(input.ArticleTitle),
|
||||
originalURL,
|
||||
normalizedURL,
|
||||
host,
|
||||
registrableDomain,
|
||||
siteKey,
|
||||
nullableString(lastPathSegment),
|
||||
confidence,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func monitoringAliasOriginalURL(input *monitoringArticleAliasInput) (string, string) {
|
||||
if input == nil {
|
||||
return "", ""
|
||||
}
|
||||
if strings.TrimSpace(input.PlatformID) == "baijiahao" {
|
||||
if articleID := strings.TrimSpace(monitoringStringValue(input.ExternalArticleID)); articleID != "" {
|
||||
return baijiahaoPublishedArticleURL(articleID), "high"
|
||||
}
|
||||
}
|
||||
if value := strings.TrimSpace(monitoringStringValue(input.ExternalArticleURL)); value != "" {
|
||||
return value, "high"
|
||||
}
|
||||
if value := strings.TrimSpace(monitoringStringValue(input.ExternalManageURL)); value != "" {
|
||||
return value, "medium"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func monitoringAliasLastPathSegment(rawURL string) *string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
segments := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||
for index := len(segments) - 1; index >= 0; index-- {
|
||||
if segment := strings.TrimSpace(segments[index]); segment != "" {
|
||||
return &segment
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -274,9 +275,31 @@ type monitoringLeasedTaskRow struct {
|
||||
}
|
||||
|
||||
type monitoringAliasResolution struct {
|
||||
ArticleID *int64
|
||||
PublishRecordID *int64
|
||||
NormalizedURLKey string
|
||||
ArticleID *int64
|
||||
PublishRecordID *int64
|
||||
NormalizedURLKey string
|
||||
ResolutionStatus string
|
||||
ResolutionConfidence string
|
||||
}
|
||||
|
||||
type monitoringAliasLookupInput struct {
|
||||
NormalizedURL string
|
||||
RegistrableDomain string
|
||||
SiteKey string
|
||||
Host string
|
||||
LastPathSegment *string
|
||||
TitleKey string
|
||||
}
|
||||
|
||||
type monitoringAliasCandidate struct {
|
||||
NormalizedURL string
|
||||
ArticleID *int64
|
||||
PublishRecordID *int64
|
||||
RegistrableDomain string
|
||||
SiteKey string
|
||||
Host string
|
||||
LastPathSegment string
|
||||
TitleKey string
|
||||
}
|
||||
|
||||
type monitoringCitationFact struct {
|
||||
@@ -2400,7 +2423,7 @@ func (s *MonitoringCallbackService) buildCitationFacts(ctx context.Context, tx p
|
||||
resolveContentCitations := monitoringPlatformResolvesContentCitations(platformID)
|
||||
aliasMap := map[string]monitoringAliasResolution{}
|
||||
if resolveContentCitations {
|
||||
normalizedURLs := make([]string, 0, len(inputs))
|
||||
lookupInputs := make([]monitoringAliasLookupInput, 0, len(inputs))
|
||||
seenURLs := make(map[string]struct{}, len(inputs))
|
||||
for _, item := range inputs {
|
||||
key := strings.TrimSpace(monitoringStringValue(item.NormalizedURL))
|
||||
@@ -2414,11 +2437,23 @@ func (s *MonitoringCallbackService) buildCitationFacts(ctx context.Context, tx p
|
||||
continue
|
||||
}
|
||||
seenURLs[key] = struct{}{}
|
||||
normalizedURLs = append(normalizedURLs, key)
|
||||
host, registrableDomain, _, _ := deriveCitationURLParts(item.URL, key)
|
||||
siteKey := strings.TrimSpace(monitoringStringValue(item.SiteKey))
|
||||
if siteKey == "" {
|
||||
siteKey = registrableDomain
|
||||
}
|
||||
lookupInputs = append(lookupInputs, monitoringAliasLookupInput{
|
||||
NormalizedURL: key,
|
||||
RegistrableDomain: strings.ToLower(strings.TrimSpace(registrableDomain)),
|
||||
SiteKey: strings.ToLower(strings.TrimSpace(siteKey)),
|
||||
Host: strings.ToLower(strings.TrimSpace(host)),
|
||||
LastPathSegment: monitoringAliasLastPathSegment(key),
|
||||
TitleKey: normalizeMonitoringAliasTitle(monitoringStringValue(item.Title)),
|
||||
})
|
||||
}
|
||||
|
||||
var err error
|
||||
aliasMap, err = s.loadAliasResolutions(ctx, tx, tenantID, normalizedURLs)
|
||||
aliasMap, err = s.loadAliasResolutions(ctx, tx, tenantID, lookupInputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2443,40 +2478,205 @@ func (s *MonitoringCallbackService) buildCitationFacts(ctx context.Context, tx p
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx pgx.Tx, tenantID int64, normalizedURLs []string) (map[string]monitoringAliasResolution, error) {
|
||||
func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx pgx.Tx, tenantID int64, lookupInputs []monitoringAliasLookupInput) (map[string]monitoringAliasResolution, error) {
|
||||
result := make(map[string]monitoringAliasResolution)
|
||||
if len(normalizedURLs) == 0 {
|
||||
if len(lookupInputs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
normalizedURLs := make([]string, 0, len(lookupInputs))
|
||||
domainKeys := make([]string, 0, len(lookupInputs)*3)
|
||||
seenDomainKeys := map[string]struct{}{}
|
||||
for _, input := range lookupInputs {
|
||||
if input.NormalizedURL != "" {
|
||||
normalizedURLs = append(normalizedURLs, input.NormalizedURL)
|
||||
}
|
||||
for _, key := range []string{input.RegistrableDomain, input.SiteKey, input.Host} {
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenDomainKeys[key]; ok {
|
||||
continue
|
||||
}
|
||||
seenDomainKeys[key] = struct{}{}
|
||||
domainKeys = append(domainKeys, key)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT normalized_url, article_id, publish_record_id
|
||||
SELECT
|
||||
normalized_url,
|
||||
article_id,
|
||||
publish_record_id,
|
||||
registrable_domain,
|
||||
site_key,
|
||||
host,
|
||||
last_path_segment,
|
||||
article_title_snapshot
|
||||
FROM monitoring_article_url_aliases
|
||||
WHERE tenant_id = $1
|
||||
AND normalized_url = ANY($2)
|
||||
`, tenantID, normalizedURLs)
|
||||
AND (
|
||||
normalized_url = ANY($2::text[])
|
||||
OR NULLIF(LOWER(TRIM(registrable_domain)), '') = ANY($3::text[])
|
||||
OR NULLIF(LOWER(TRIM(site_key)), '') = ANY($3::text[])
|
||||
OR NULLIF(LOWER(TRIM(host)), '') = ANY($3::text[])
|
||||
)
|
||||
`, tenantID, normalizedURLs, domainKeys)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50041, "alias_lookup_failed", "failed to resolve article aliases")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
candidates := make([]monitoringAliasCandidate, 0)
|
||||
for rows.Next() {
|
||||
var normalizedURL string
|
||||
var candidate monitoringAliasCandidate
|
||||
var articleID sql.NullInt64
|
||||
var publishRecordID sql.NullInt64
|
||||
if scanErr := rows.Scan(&normalizedURL, &articleID, &publishRecordID); scanErr != nil {
|
||||
var registrableDomain sql.NullString
|
||||
var siteKey sql.NullString
|
||||
var host sql.NullString
|
||||
var lastPathSegment sql.NullString
|
||||
var articleTitle sql.NullString
|
||||
if scanErr := rows.Scan(
|
||||
&candidate.NormalizedURL,
|
||||
&articleID,
|
||||
&publishRecordID,
|
||||
®istrableDomain,
|
||||
&siteKey,
|
||||
&host,
|
||||
&lastPathSegment,
|
||||
&articleTitle,
|
||||
); scanErr != nil {
|
||||
return nil, response.ErrInternal(50041, "alias_scan_failed", "failed to parse article alias resolution")
|
||||
}
|
||||
result[normalizedURL] = monitoringAliasResolution{
|
||||
ArticleID: nullableInt64Value(articleID),
|
||||
PublishRecordID: nullableInt64Value(publishRecordID),
|
||||
NormalizedURLKey: normalizedURL,
|
||||
candidate.ArticleID = nullableInt64Value(articleID)
|
||||
candidate.PublishRecordID = nullableInt64Value(publishRecordID)
|
||||
candidate.RegistrableDomain = strings.ToLower(strings.TrimSpace(nullableStringText(registrableDomain)))
|
||||
candidate.SiteKey = strings.ToLower(strings.TrimSpace(nullableStringText(siteKey)))
|
||||
candidate.Host = strings.ToLower(strings.TrimSpace(nullableStringText(host)))
|
||||
candidate.LastPathSegment = strings.TrimSpace(nullableStringText(lastPathSegment))
|
||||
candidate.TitleKey = normalizeMonitoringAliasTitle(nullableStringText(articleTitle))
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, response.ErrInternal(50041, "alias_scan_failed", "failed to iterate article aliases")
|
||||
}
|
||||
|
||||
for _, input := range lookupInputs {
|
||||
bestScore := -1
|
||||
bestAmbiguous := false
|
||||
var best monitoringAliasCandidate
|
||||
domainMatchedArticleIDs := map[int64]struct{}{}
|
||||
for _, candidate := range candidates {
|
||||
if monitoringAliasDomainMatches(input, candidate) && candidate.ArticleID != nil {
|
||||
domainMatchedArticleIDs[*candidate.ArticleID] = struct{}{}
|
||||
}
|
||||
score := scoreMonitoringAliasCandidate(input, candidate)
|
||||
if score < 0 {
|
||||
continue
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestAmbiguous = false
|
||||
best = candidate
|
||||
continue
|
||||
}
|
||||
if score == bestScore && !sameInt64Pointer(candidate.ArticleID, best.ArticleID) {
|
||||
bestAmbiguous = true
|
||||
}
|
||||
}
|
||||
if bestScore == 40 && len(domainMatchedArticleIDs) == 1 {
|
||||
bestScore = 60
|
||||
}
|
||||
|
||||
if bestScore < 60 || bestAmbiguous {
|
||||
continue
|
||||
}
|
||||
confidence := "medium"
|
||||
status := "domain_matched"
|
||||
if bestScore >= 100 {
|
||||
confidence = "high"
|
||||
status = "resolved"
|
||||
} else if bestScore < 80 {
|
||||
confidence = "low"
|
||||
}
|
||||
result[input.NormalizedURL] = monitoringAliasResolution{
|
||||
ArticleID: cloneInt64(best.ArticleID),
|
||||
PublishRecordID: cloneInt64(best.PublishRecordID),
|
||||
NormalizedURLKey: best.NormalizedURL,
|
||||
ResolutionStatus: status,
|
||||
ResolutionConfidence: confidence,
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scoreMonitoringAliasCandidate(input monitoringAliasLookupInput, candidate monitoringAliasCandidate) int {
|
||||
if input.NormalizedURL != "" && candidate.NormalizedURL == input.NormalizedURL {
|
||||
return 100
|
||||
}
|
||||
|
||||
if !monitoringAliasDomainMatches(input, candidate) {
|
||||
return -1
|
||||
}
|
||||
|
||||
score := 40
|
||||
if input.LastPathSegment != nil && strings.TrimSpace(*input.LastPathSegment) != "" && candidate.LastPathSegment == strings.TrimSpace(*input.LastPathSegment) {
|
||||
score += 30
|
||||
}
|
||||
if input.TitleKey != "" && candidate.TitleKey != "" {
|
||||
switch {
|
||||
case input.TitleKey == candidate.TitleKey:
|
||||
score += 30
|
||||
case strings.Contains(input.TitleKey, candidate.TitleKey) || strings.Contains(candidate.TitleKey, input.TitleKey):
|
||||
score += 15
|
||||
}
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func monitoringAliasDomainMatches(input monitoringAliasLookupInput, candidate monitoringAliasCandidate) bool {
|
||||
inputKeys := []string{input.RegistrableDomain, input.SiteKey, input.Host}
|
||||
candidateKeys := []string{candidate.RegistrableDomain, candidate.SiteKey, candidate.Host}
|
||||
for _, inputKey := range inputKeys {
|
||||
inputKey = strings.ToLower(strings.TrimSpace(inputKey))
|
||||
if inputKey == "" {
|
||||
continue
|
||||
}
|
||||
for _, candidateKey := range candidateKeys {
|
||||
if inputKey == strings.ToLower(strings.TrimSpace(candidateKey)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeMonitoringAliasTitle(value string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
||||
return unicode.ToLower(r)
|
||||
}
|
||||
return -1
|
||||
}, value)
|
||||
}
|
||||
|
||||
func nullableStringText(value sql.NullString) string {
|
||||
if !value.Valid {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(value.String)
|
||||
}
|
||||
|
||||
func sameInt64Pointer(left, right *int64) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == right
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
func (s *MonitoringCallbackService) insertCitationFact(ctx context.Context, tx pgx.Tx, task *monitoringCollectTask, runID int64, fact monitoringCitationFact) error {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO monitoring_citation_facts (
|
||||
@@ -2809,10 +3009,13 @@ func buildMonitoringCitationFact(input MonitoringSourceItem, aliasMap map[string
|
||||
|
||||
var articleID *int64
|
||||
var publishRecordID *int64
|
||||
var aliasResolution *monitoringAliasResolution
|
||||
if resolveContentCitations {
|
||||
articleID = cloneInt64(input.ArticleID)
|
||||
publishRecordID = cloneInt64(input.PublishRecordID)
|
||||
if alias, ok := aliasMap[normalizedURL]; ok {
|
||||
aliasCopy := alias
|
||||
aliasResolution = &aliasCopy
|
||||
if articleID == nil {
|
||||
articleID = cloneInt64(alias.ArticleID)
|
||||
}
|
||||
@@ -2824,11 +3027,19 @@ func buildMonitoringCitationFact(input MonitoringSourceItem, aliasMap map[string
|
||||
|
||||
resolutionStatus := strings.TrimSpace(monitoringStringValue(input.ResolutionStatus))
|
||||
if resolutionStatus == "" {
|
||||
resolutionStatus = "resolved"
|
||||
if aliasResolution != nil && strings.TrimSpace(aliasResolution.ResolutionStatus) != "" {
|
||||
resolutionStatus = strings.TrimSpace(aliasResolution.ResolutionStatus)
|
||||
} else {
|
||||
resolutionStatus = "resolved"
|
||||
}
|
||||
}
|
||||
resolutionConfidence := strings.TrimSpace(monitoringStringValue(input.ResolutionConfidence))
|
||||
if resolutionConfidence == "" {
|
||||
resolutionConfidence = "high"
|
||||
if aliasResolution != nil && strings.TrimSpace(aliasResolution.ResolutionConfidence) != "" {
|
||||
resolutionConfidence = strings.TrimSpace(aliasResolution.ResolutionConfidence)
|
||||
} else {
|
||||
resolutionConfidence = "high"
|
||||
}
|
||||
}
|
||||
|
||||
return monitoringCitationFact{
|
||||
@@ -3113,7 +3324,7 @@ func normalizeCitationURL(raw string) string {
|
||||
parsed.Host = strings.ToLower(parsed.Host)
|
||||
parsed.Fragment = ""
|
||||
parsed.RawFragment = ""
|
||||
parsed.RawQuery = ""
|
||||
parsed.RawQuery = normalizeCitationQuery(parsed)
|
||||
|
||||
if parsed.Path != "/" {
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
@@ -3122,6 +3333,30 @@ func normalizeCitationURL(raw string) string {
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func normalizeCitationQuery(parsed *url.URL) string {
|
||||
if parsed == nil {
|
||||
return ""
|
||||
}
|
||||
host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
|
||||
allowedParams := []string{}
|
||||
switch {
|
||||
case host == "baijiahao.baidu.com":
|
||||
allowedParams = []string{"id"}
|
||||
}
|
||||
if len(allowedParams) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
query := parsed.Query()
|
||||
normalized := url.Values{}
|
||||
for _, key := range allowedParams {
|
||||
if value := strings.TrimSpace(query.Get(key)); value != "" {
|
||||
normalized.Set(key, value)
|
||||
}
|
||||
}
|
||||
return normalized.Encode()
|
||||
}
|
||||
|
||||
func deriveCitationURLParts(rawURL, normalizedURL string) (string, string, *string, *string) {
|
||||
candidate := strings.TrimSpace(normalizedURL)
|
||||
if candidate == "" {
|
||||
|
||||
@@ -425,6 +425,26 @@ func TestBuildMonitoringCitationFactCanDisableContentResolution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreMonitoringAliasCandidateUsesDomainWithTitleEvidence(t *testing.T) {
|
||||
lastPath := "article-42"
|
||||
input := monitoringAliasLookupInput{
|
||||
NormalizedURL: "https://m.example.com/article-42",
|
||||
RegistrableDomain: "example.com",
|
||||
LastPathSegment: &lastPath,
|
||||
TitleKey: normalizeMonitoringAliasTitle("北京口腔医院全面测评"),
|
||||
}
|
||||
candidate := monitoringAliasCandidate{
|
||||
NormalizedURL: "https://www.example.com/posts/article-42",
|
||||
RegistrableDomain: "example.com",
|
||||
LastPathSegment: "article-42",
|
||||
TitleKey: normalizeMonitoringAliasTitle("北京口腔医院全面测评"),
|
||||
}
|
||||
|
||||
if score := scoreMonitoringAliasCandidate(input, candidate); score < 90 {
|
||||
t.Fatalf("scoreMonitoringAliasCandidate() = %d, want strong domain/title match", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMonitoringRawPayloadDeepSeekIncludesSearchResultsInCitationInputs(t *testing.T) {
|
||||
searchTitle := "搜索网页结果"
|
||||
|
||||
|
||||
@@ -74,12 +74,25 @@ func (s *MonitoringService) WithRedis(redis *goredis.Client) *MonitoringService
|
||||
type MonitoringDashboardCompositeResponse struct {
|
||||
Overview MonitoringOverview `json:"overview"`
|
||||
Runtime MonitoringDashboardRuntime `json:"runtime"`
|
||||
CitationWindow MonitoringDashboardWindow `json:"citation_window"`
|
||||
PlatformBreakdown []MonitoringPlatformDaily `json:"platform_breakdown"`
|
||||
HotQuestions []MonitoringHotQuestion `json:"hot_questions"`
|
||||
CitationRanking []MonitoringCitationRanking `json:"citation_ranking"`
|
||||
CitedArticles []MonitoringCitedArticle `json:"cited_articles"`
|
||||
}
|
||||
|
||||
type MonitoringCitationSummaryResponse struct {
|
||||
CitationWindow MonitoringDashboardWindow `json:"citation_window"`
|
||||
CitationRanking []MonitoringCitationRanking `json:"citation_ranking"`
|
||||
CitedArticles []MonitoringCitedArticle `json:"cited_articles"`
|
||||
}
|
||||
|
||||
type MonitoringDashboardWindow struct {
|
||||
Days int `json:"days"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
|
||||
type MonitoringDashboardRuntime struct {
|
||||
CurrentUserClientOnline bool `json:"current_user_client_online"`
|
||||
PlatformAuthorizationStatus string `json:"platform_authorization_status"`
|
||||
@@ -126,12 +139,15 @@ type MonitoringHotQuestion struct {
|
||||
}
|
||||
|
||||
type MonitoringCitationRanking struct {
|
||||
AIPlatformID string `json:"ai_platform_id"`
|
||||
PlatformName string `json:"platform_name"`
|
||||
SampleStatus string `json:"sample_status"`
|
||||
CitedAnswerCount int64 `json:"cited_answer_count"`
|
||||
CitedArticleCount int64 `json:"cited_article_count"`
|
||||
CitationRate *float64 `json:"citation_rate"`
|
||||
AIPlatformID string `json:"ai_platform_id"`
|
||||
PlatformName string `json:"platform_name"`
|
||||
SampleStatus string `json:"sample_status"`
|
||||
CitedAnswerCount int64 `json:"cited_answer_count"`
|
||||
CitedArticleCount int64 `json:"cited_article_count"`
|
||||
CitationSourceCount int64 `json:"citation_source_count"`
|
||||
SaaSSourceCount int64 `json:"saas_source_count"`
|
||||
SaaSSourceRate *float64 `json:"saas_source_rate"`
|
||||
CitationRate *float64 `json:"citation_rate"`
|
||||
}
|
||||
|
||||
type MonitoringCitedArticle struct {
|
||||
@@ -140,6 +156,7 @@ type MonitoringCitedArticle struct {
|
||||
PublishPlatform string `json:"publish_platform"`
|
||||
CitationCount int64 `json:"citation_count"`
|
||||
CitationRate *float64 `json:"citation_rate"`
|
||||
SourceShare *float64 `json:"source_share"`
|
||||
}
|
||||
|
||||
type MonitoringQuestionDetailResponse struct {
|
||||
@@ -347,7 +364,6 @@ func (s *MonitoringService) DashboardComposite(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
questionIDs := configuredQuestionIDs(configuredQuestions)
|
||||
|
||||
platforms := defaultMonitoringPlatformMetadata()
|
||||
selectedPlatformID := normalizedOptionalMonitoringPlatformPointer(aiPlatformID)
|
||||
@@ -380,23 +396,59 @@ func (s *MonitoringService) DashboardComposite(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
citationRanking, err := s.loadCitationRanking(ctx, actor.TenantID, brand.ID, questionIDs, startDate, endDate, accessStates, selectedPlatformID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
citedArticles, err := s.loadCitedArticles(ctx, actor.TenantID, brand.ID, questionIDs, startDate, endDate, selectedPlatformID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &MonitoringDashboardCompositeResponse{
|
||||
Overview: overview,
|
||||
Runtime: runtime,
|
||||
Overview: overview,
|
||||
Runtime: runtime,
|
||||
CitationWindow: MonitoringDashboardWindow{
|
||||
Days: defaultTrackingDays,
|
||||
From: endDate.AddDate(0, 0, -(defaultTrackingDays - 1)).Format("2006-01-02"),
|
||||
To: endDate.Format("2006-01-02"),
|
||||
},
|
||||
PlatformBreakdown: platformBreakdown,
|
||||
HotQuestions: hotQuestions,
|
||||
CitationRanking: citationRanking,
|
||||
CitedArticles: citedArticles,
|
||||
CitationRanking: []MonitoringCitationRanking{},
|
||||
CitedArticles: []MonitoringCitedArticle{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *MonitoringService) CitationSummary(
|
||||
ctx context.Context,
|
||||
days int,
|
||||
) (*MonitoringCitationSummaryResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
workspaceID := auth.CurrentWorkspaceID(ctx)
|
||||
|
||||
quota, err := s.loadQuota(ctx, actor, workspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
citationWindowDays := normalizeCitationWindowDays(days)
|
||||
startDate, endDate := trackingDateWindow(citationWindowDays)
|
||||
|
||||
accessStates, err := s.loadAccessStates(ctx, actor.TenantID, workspaceID, quota.PrimaryClientID, endDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
citationRanking, err := s.loadCitationRanking(ctx, actor.TenantID, 0, nil, startDate, endDate, accessStates, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
citedArticles, err := s.loadCitedArticles(ctx, actor.TenantID, 0, nil, startDate, endDate, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &MonitoringCitationSummaryResponse{
|
||||
CitationWindow: MonitoringDashboardWindow{
|
||||
Days: citationWindowDays,
|
||||
From: startDate.Format("2006-01-02"),
|
||||
To: endDate.Format("2006-01-02"),
|
||||
},
|
||||
CitationRanking: citationRanking,
|
||||
CitedArticles: citedArticles,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -509,12 +561,12 @@ func (s *MonitoringService) QuestionDetail(ctx context.Context, brandID, questio
|
||||
})
|
||||
}
|
||||
|
||||
citationAnalysis, err := s.loadQuestionCitationAnalysis(ctx, actor.TenantID, brand.ID, questionID, hashBytes, fromDate, toDate, selectedPlatformID)
|
||||
citationAnalysis, err := s.loadQuestionCitationAnalysis(ctx, actor.TenantID, runIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
contentCitations, err := s.loadQuestionContentCitations(ctx, actor.TenantID, brand.ID, questionID, hashBytes, fromDate, toDate, selectedPlatformID)
|
||||
contentCitations, err := s.loadQuestionContentCitations(ctx, actor.TenantID, runIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2430,13 +2482,26 @@ func (s *MonitoringService) loadCitationRanking(
|
||||
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
|
||||
|
||||
rows, err := s.monitoringPool.Query(ctx, `
|
||||
WITH run_counts AS (
|
||||
WITH published_domains AS (
|
||||
SELECT DISTINCT domain_key
|
||||
FROM (
|
||||
SELECT NULLIF(LOWER(TRIM(host)), '') AS domain_key
|
||||
FROM monitoring_article_url_aliases
|
||||
WHERE tenant_id = $1
|
||||
UNION
|
||||
SELECT NULLIF(LOWER(TRIM(site_key)), '') AS domain_key
|
||||
FROM monitoring_article_url_aliases
|
||||
WHERE tenant_id = $1
|
||||
) domains
|
||||
WHERE domain_key IS NOT NULL
|
||||
),
|
||||
run_counts AS (
|
||||
SELECT
|
||||
r.ai_platform_id,
|
||||
COUNT(*) AS sample_count
|
||||
FROM question_monitor_runs r
|
||||
WHERE r.tenant_id = $1
|
||||
AND r.brand_id = $2
|
||||
AND ($2::bigint = 0 OR r.brand_id = $2)
|
||||
AND r.collector_type = $3
|
||||
AND r.status = 'succeeded'
|
||||
AND r.business_date BETWEEN $4::date AND $5::date
|
||||
@@ -2444,36 +2509,65 @@ func (s *MonitoringService) loadCitationRanking(
|
||||
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
|
||||
GROUP BY r.ai_platform_id
|
||||
),
|
||||
citation_counts AS (
|
||||
citation_sources AS (
|
||||
SELECT
|
||||
r.ai_platform_id,
|
||||
COUNT(DISTINCT cf.run_id) FILTER (WHERE cf.article_id IS NOT NULL AND r.ai_platform_id <> 'qwen') AS cited_answer_count,
|
||||
COUNT(DISTINCT cf.article_id) FILTER (WHERE cf.article_id IS NOT NULL AND r.ai_platform_id <> 'qwen') AS cited_article_count
|
||||
cf.id,
|
||||
cf.run_id,
|
||||
cf.article_id,
|
||||
(
|
||||
cf.article_id IS NOT NULL
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM published_domains pd
|
||||
WHERE pd.domain_key = ANY(ARRAY[
|
||||
LOWER(NULLIF(cf.registrable_domain, '')),
|
||||
LOWER(NULLIF(cf.site_key, '')),
|
||||
LOWER(NULLIF(cf.host, ''))
|
||||
])
|
||||
)
|
||||
) AS matched_saas_source
|
||||
FROM question_monitor_runs r
|
||||
JOIN monitoring_citation_facts cf
|
||||
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
|
||||
WHERE r.tenant_id = $1
|
||||
AND r.brand_id = $2
|
||||
AND ($2::bigint = 0 OR r.brand_id = $2)
|
||||
AND r.collector_type = $3
|
||||
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 = ANY($7))
|
||||
GROUP BY r.ai_platform_id
|
||||
),
|
||||
citation_counts AS (
|
||||
SELECT
|
||||
ai_platform_id,
|
||||
COUNT(*) AS citation_source_count,
|
||||
COUNT(*) FILTER (WHERE matched_saas_source AND ai_platform_id <> 'qwen') AS saas_source_count,
|
||||
COUNT(DISTINCT run_id) FILTER (WHERE matched_saas_source AND ai_platform_id <> 'qwen') AS cited_answer_count,
|
||||
COUNT(DISTINCT article_id) FILTER (WHERE article_id IS NOT NULL AND ai_platform_id <> 'qwen') AS cited_article_count
|
||||
FROM citation_sources
|
||||
GROUP BY ai_platform_id
|
||||
)
|
||||
SELECT
|
||||
rc.ai_platform_id,
|
||||
rc.sample_count,
|
||||
COALESCE(cc.cited_answer_count, 0) AS cited_answer_count,
|
||||
COALESCE(cc.cited_article_count, 0) AS cited_article_count,
|
||||
COALESCE(cc.citation_source_count, 0) AS citation_source_count,
|
||||
COALESCE(cc.saas_source_count, 0) AS saas_source_count,
|
||||
CASE
|
||||
WHEN COALESCE(cc.citation_source_count, 0) > 0
|
||||
THEN COALESCE(cc.saas_source_count, 0)::double precision / cc.citation_source_count::double precision
|
||||
ELSE NULL
|
||||
END AS saas_source_rate,
|
||||
CASE
|
||||
WHEN rc.sample_count > 0 THEN COALESCE(cc.cited_answer_count, 0)::double precision / rc.sample_count
|
||||
ELSE NULL
|
||||
END AS citation_rate
|
||||
FROM run_counts rc
|
||||
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
|
||||
WHERE COALESCE(cc.saas_source_count, 0) > 0
|
||||
ORDER BY cited_answer_count DESC, saas_source_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), nullableStringArray(platformQueryIDs))
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50041, "query_failed", "failed to load citation ranking")
|
||||
@@ -2481,9 +2575,11 @@ func (s *MonitoringService) loadCitationRanking(
|
||||
defer rows.Close()
|
||||
|
||||
type citationRankingAggregate struct {
|
||||
CitedAnswerCount int64
|
||||
CitedArticleCount int64
|
||||
SampleCount int64
|
||||
CitedAnswerCount int64
|
||||
CitedArticleCount int64
|
||||
CitationSourceCount int64
|
||||
SaaSSourceCount int64
|
||||
SampleCount int64
|
||||
}
|
||||
|
||||
aggregates := make(map[string]citationRankingAggregate)
|
||||
@@ -2492,7 +2588,9 @@ func (s *MonitoringService) loadCitationRanking(
|
||||
var sampleCount int64
|
||||
var citedAnswerCount int64
|
||||
var citedArticleCount int64
|
||||
if scanErr := rows.Scan(&platformID, &sampleCount, &citedAnswerCount, &citedArticleCount, new(sql.NullFloat64)); scanErr != nil {
|
||||
var citationSourceCount int64
|
||||
var saasSourceCount int64
|
||||
if scanErr := rows.Scan(&platformID, &sampleCount, &citedAnswerCount, &citedArticleCount, &citationSourceCount, &saasSourceCount, new(sql.NullFloat64), new(sql.NullFloat64)); scanErr != nil {
|
||||
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking")
|
||||
}
|
||||
platformID = normalizeMonitoringPlatformID(platformID)
|
||||
@@ -2502,6 +2600,8 @@ func (s *MonitoringService) loadCitationRanking(
|
||||
item := aggregates[platformID]
|
||||
item.CitedAnswerCount += citedAnswerCount
|
||||
item.CitedArticleCount += citedArticleCount
|
||||
item.CitationSourceCount += citationSourceCount
|
||||
item.SaaSSourceCount += saasSourceCount
|
||||
item.SampleCount += sampleCount
|
||||
aggregates[platformID] = item
|
||||
}
|
||||
@@ -2509,18 +2609,24 @@ func (s *MonitoringService) loadCitationRanking(
|
||||
items := make([]MonitoringCitationRanking, 0, len(aggregates))
|
||||
for platformID, aggregate := range aggregates {
|
||||
items = append(items, MonitoringCitationRanking{
|
||||
AIPlatformID: platformID,
|
||||
PlatformName: platformDisplayName(platformID),
|
||||
SampleStatus: derivePlatformSampleStatus("", accessStates[platformID]),
|
||||
CitedAnswerCount: aggregate.CitedAnswerCount,
|
||||
CitedArticleCount: aggregate.CitedArticleCount,
|
||||
CitationRate: divideAsPointer(aggregate.CitedAnswerCount, aggregate.SampleCount),
|
||||
AIPlatformID: platformID,
|
||||
PlatformName: platformDisplayName(platformID),
|
||||
SampleStatus: derivePlatformSampleStatus("", accessStates[platformID]),
|
||||
CitedAnswerCount: aggregate.CitedAnswerCount,
|
||||
CitedArticleCount: aggregate.CitedArticleCount,
|
||||
CitationSourceCount: aggregate.CitationSourceCount,
|
||||
SaaSSourceCount: aggregate.SaaSSourceCount,
|
||||
SaaSSourceRate: divideAsPointer(aggregate.SaaSSourceCount, aggregate.CitationSourceCount),
|
||||
CitationRate: divideAsPointer(aggregate.CitedAnswerCount, aggregate.SampleCount),
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(left, right int) bool {
|
||||
if items[left].CitedAnswerCount != items[right].CitedAnswerCount {
|
||||
return items[left].CitedAnswerCount > items[right].CitedAnswerCount
|
||||
}
|
||||
if items[left].SaaSSourceCount != items[right].SaaSSourceCount {
|
||||
return items[left].SaaSSourceCount > items[right].SaaSSourceCount
|
||||
}
|
||||
if items[left].CitedArticleCount != items[right].CitedArticleCount {
|
||||
return items[left].CitedArticleCount > items[right].CitedArticleCount
|
||||
}
|
||||
@@ -2547,7 +2653,7 @@ func (s *MonitoringService) loadCitedArticles(
|
||||
SELECT COUNT(*)
|
||||
FROM question_monitor_runs r
|
||||
WHERE r.tenant_id = $1
|
||||
AND r.brand_id = $2
|
||||
AND ($2::bigint = 0 OR r.brand_id = $2)
|
||||
AND r.collector_type = $3
|
||||
AND r.status = 'succeeded'
|
||||
AND r.business_date BETWEEN $4::date AND $5::date
|
||||
@@ -2558,26 +2664,51 @@ func (s *MonitoringService) loadCitedArticles(
|
||||
}
|
||||
|
||||
rows, err := s.monitoringPool.Query(ctx, `
|
||||
WITH article_counts AS (
|
||||
SELECT
|
||||
cf.article_id,
|
||||
COALESCE(MAX(alias.article_title_snapshot), '未命名文章') AS article_title,
|
||||
COALESCE(MAX(alias.publish_platform_name_snapshot), '已发布内容') AS publish_platform,
|
||||
COUNT(*) AS citation_count
|
||||
FROM monitoring_citation_facts cf
|
||||
JOIN question_monitor_runs r
|
||||
ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
alias.article_title_snapshot,
|
||||
alias.publish_platform_name_snapshot
|
||||
FROM monitoring_article_url_aliases alias
|
||||
WHERE alias.tenant_id = cf.tenant_id
|
||||
AND alias.article_id = cf.article_id
|
||||
ORDER BY
|
||||
(alias.publish_record_id = cf.publish_record_id) DESC NULLS LAST,
|
||||
alias.updated_at DESC,
|
||||
alias.id DESC
|
||||
LIMIT 1
|
||||
) alias ON TRUE
|
||||
WHERE cf.tenant_id = $1
|
||||
AND ($2::bigint = 0 OR cf.brand_id = $2)
|
||||
AND cf.article_id IS NOT NULL
|
||||
AND r.ai_platform_id <> 'qwen'
|
||||
AND r.collector_type = $3
|
||||
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 = ANY($7))
|
||||
GROUP BY cf.article_id
|
||||
),
|
||||
totals AS (
|
||||
SELECT COALESCE(SUM(citation_count), 0) AS total_article_citation_count
|
||||
FROM article_counts
|
||||
)
|
||||
SELECT
|
||||
cf.article_id,
|
||||
COALESCE(MAX(alias.article_title_snapshot), '未命名文章') AS article_title,
|
||||
COALESCE(MAX(alias.publish_platform_name_snapshot), '已发布内容') AS publish_platform,
|
||||
COUNT(*) AS citation_count
|
||||
FROM monitoring_citation_facts cf
|
||||
JOIN question_monitor_runs r
|
||||
ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id
|
||||
LEFT JOIN monitoring_article_url_aliases alias
|
||||
ON alias.tenant_id = cf.tenant_id AND alias.article_id = cf.article_id
|
||||
WHERE cf.tenant_id = $1
|
||||
AND cf.brand_id = $2
|
||||
AND cf.article_id IS NOT NULL
|
||||
AND r.ai_platform_id <> 'qwen'
|
||||
AND r.collector_type = $3
|
||||
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 = ANY($7))
|
||||
GROUP BY cf.article_id
|
||||
cf.article_title,
|
||||
cf.publish_platform,
|
||||
cf.citation_count,
|
||||
t.total_article_citation_count
|
||||
FROM article_counts cf
|
||||
CROSS JOIN totals t
|
||||
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), nullableStringArray(platformQueryIDs))
|
||||
@@ -2592,7 +2723,8 @@ func (s *MonitoringService) loadCitedArticles(
|
||||
var title string
|
||||
var publishPlatform string
|
||||
var citationCount int64
|
||||
if scanErr := rows.Scan(&articleID, &title, &publishPlatform, &citationCount); scanErr != nil {
|
||||
var totalArticleCitationCount int64
|
||||
if scanErr := rows.Scan(&articleID, &title, &publishPlatform, &citationCount, &totalArticleCitationCount); scanErr != nil {
|
||||
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse cited articles")
|
||||
}
|
||||
items = append(items, MonitoringCitedArticle{
|
||||
@@ -2601,6 +2733,7 @@ func (s *MonitoringService) loadCitedArticles(
|
||||
PublishPlatform: publishPlatform,
|
||||
CitationCount: citationCount,
|
||||
CitationRate: divideAsPointer(citationCount, totalSampleCount),
|
||||
SourceShare: divideAsPointer(citationCount, totalArticleCitationCount),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2902,8 +3035,10 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, tenantID, brandID, questionID int64, hashBytes []byte, fromDate, toDate time.Time, aiPlatformID *string) ([]MonitoringQuestionCitationStats, error) {
|
||||
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
|
||||
func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, tenantID int64, runIDs []int64) ([]MonitoringQuestionCitationStats, error) {
|
||||
if len(runIDs) == 0 {
|
||||
return []MonitoringQuestionCitationStats{}, nil
|
||||
}
|
||||
|
||||
rows, err := s.monitoringPool.Query(ctx, `
|
||||
WITH filtered_facts AS (
|
||||
@@ -2918,13 +3053,8 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
|
||||
JOIN monitoring_citation_facts cf
|
||||
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
|
||||
WHERE r.tenant_id = $1
|
||||
AND r.brand_id = $2
|
||||
AND r.question_id = $3
|
||||
AND r.question_hash = $4
|
||||
AND r.collector_type = $5
|
||||
AND r.status = 'succeeded'
|
||||
AND r.business_date BETWEEN $6::date AND $7::date
|
||||
AND ($8::text[] IS NULL OR r.ai_platform_id = ANY($8))
|
||||
AND r.id = ANY($2)
|
||||
),
|
||||
domain_keys AS (
|
||||
SELECT DISTINCT host, registrable_domain, site_key
|
||||
@@ -3003,7 +3133,7 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
|
||||
JOIN totals t
|
||||
ON t.ai_platform_id = g.ai_platform_id
|
||||
ORDER BY g.ai_platform_id ASC, g.citation_count DESC, g.site_name ASC
|
||||
`, tenantID, brandID, questionID, hashBytes, monitoringCollectorType, fromDate.Format("2006-01-02"), toDate.Format("2006-01-02"), nullableStringArray(platformQueryIDs))
|
||||
`, tenantID, runIDs)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50041, "query_failed", "failed to load question citation analysis")
|
||||
}
|
||||
@@ -3071,8 +3201,10 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, tenantID, brandID, questionID int64, hashBytes []byte, fromDate, toDate time.Time, aiPlatformID *string) ([]MonitoringQuestionContentCitation, error) {
|
||||
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
|
||||
func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, tenantID int64, runIDs []int64) ([]MonitoringQuestionContentCitation, error) {
|
||||
if len(runIDs) == 0 {
|
||||
return []MonitoringQuestionContentCitation{}, nil
|
||||
}
|
||||
|
||||
rows, err := s.monitoringPool.Query(ctx, `
|
||||
WITH platform_totals AS (
|
||||
@@ -3083,13 +3215,8 @@ func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, te
|
||||
JOIN monitoring_citation_facts cf
|
||||
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
|
||||
WHERE r.tenant_id = $1
|
||||
AND r.brand_id = $2
|
||||
AND r.question_id = $3
|
||||
AND r.question_hash = $4
|
||||
AND r.collector_type = $5
|
||||
AND r.status = 'succeeded'
|
||||
AND r.business_date BETWEEN $6::date AND $7::date
|
||||
AND ($8::text[] IS NULL OR r.ai_platform_id = ANY($8))
|
||||
AND r.id = ANY($2)
|
||||
GROUP BY r.ai_platform_id
|
||||
)
|
||||
SELECT
|
||||
@@ -3107,18 +3234,13 @@ func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, te
|
||||
LEFT JOIN monitoring_article_url_aliases alias
|
||||
ON alias.tenant_id = cf.tenant_id AND alias.article_id = cf.article_id
|
||||
WHERE r.tenant_id = $1
|
||||
AND r.brand_id = $2
|
||||
AND r.question_id = $3
|
||||
AND r.question_hash = $4
|
||||
AND r.collector_type = $5
|
||||
AND r.status = 'succeeded'
|
||||
AND r.business_date BETWEEN $6::date AND $7::date
|
||||
AND cf.article_id IS NOT NULL
|
||||
AND r.ai_platform_id <> 'qwen'
|
||||
AND ($8::text[] IS NULL OR r.ai_platform_id = ANY($8))
|
||||
AND r.id = ANY($2)
|
||||
GROUP BY cf.article_id, r.ai_platform_id
|
||||
ORDER BY citation_count DESC, cf.article_id ASC
|
||||
`, tenantID, brandID, questionID, hashBytes, monitoringCollectorType, fromDate.Format("2006-01-02"), toDate.Format("2006-01-02"), nullableStringArray(platformQueryIDs))
|
||||
`, tenantID, runIDs)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50041, "query_failed", "failed to load content citations")
|
||||
}
|
||||
@@ -3326,6 +3448,13 @@ func normalizeTrackingDays(days int) int {
|
||||
return days
|
||||
}
|
||||
|
||||
func normalizeCitationWindowDays(days int) int {
|
||||
if days == maxTrackingDays {
|
||||
return maxTrackingDays
|
||||
}
|
||||
return defaultTrackingDays
|
||||
}
|
||||
|
||||
func resolveMonitoringPlatforms(enabled []string) []monitoringPlatformMetadata {
|
||||
if len(enabled) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -28,8 +28,21 @@ type platformAccountSeed struct {
|
||||
}
|
||||
|
||||
type desktopPublishSyncOutcome struct {
|
||||
TenantID int64
|
||||
ArticleID int64
|
||||
TenantID int64
|
||||
ArticleID int64
|
||||
ArticleAlias *monitoringArticleAliasInput
|
||||
}
|
||||
|
||||
type monitoringArticleAliasInput struct {
|
||||
TenantID int64
|
||||
ArticleID int64
|
||||
PublishRecordID int64
|
||||
PlatformID string
|
||||
PlatformName string
|
||||
ArticleTitle string
|
||||
ExternalArticleID *string
|
||||
ExternalArticleURL *string
|
||||
ExternalManageURL *string
|
||||
}
|
||||
|
||||
func loadPublishableArticleMeta(ctx context.Context, tx pgx.Tx, tenantID, articleID int64) (*publishableArticleMeta, error) {
|
||||
@@ -136,6 +149,10 @@ func syncDesktopPublishTaskState(
|
||||
publishedAt = &now
|
||||
}
|
||||
|
||||
externalArticleID := extractStringPointer(resultPayload, "external_article_id", "externalArticleId")
|
||||
externalArticleURL := extractStringPointer(resultPayload, "external_article_url", "externalArticleUrl")
|
||||
externalManageURL := extractStringPointer(resultPayload, "external_manage_url", "externalManageUrl", "review_url", "reviewUrl")
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE publish_records
|
||||
SET status = $1,
|
||||
@@ -149,9 +166,9 @@ func syncDesktopPublishTaskState(
|
||||
updated_at = NOW()
|
||||
WHERE id = $9 AND tenant_id = $10
|
||||
`, publishStatus,
|
||||
extractStringPointer(resultPayload, "external_article_id", "externalArticleId"),
|
||||
extractStringPointer(resultPayload, "external_article_url", "externalArticleUrl"),
|
||||
extractStringPointer(resultPayload, "external_manage_url", "externalManageUrl", "review_url", "reviewUrl"),
|
||||
externalArticleID,
|
||||
externalArticleURL,
|
||||
externalManageURL,
|
||||
publishedAt,
|
||||
nullableJSON(task.Payload),
|
||||
nullableJSON(task.Result),
|
||||
@@ -183,10 +200,42 @@ func syncDesktopPublishTaskState(
|
||||
return nil, response.ErrInternal(50055, "article_publish_update_failed", "failed to update article publish status")
|
||||
}
|
||||
|
||||
return &desktopPublishSyncOutcome{
|
||||
outcome := &desktopPublishSyncOutcome{
|
||||
TenantID: task.TenantID,
|
||||
ArticleID: articleID,
|
||||
}, nil
|
||||
}
|
||||
if publishStatus == "success" {
|
||||
var platformID string
|
||||
var platformName string
|
||||
var articleTitle string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT
|
||||
pr.platform_id,
|
||||
COALESCE(mp.name, pr.platform_id) AS platform_name,
|
||||
COALESCE(NULLIF(av.title, ''), '未命名文章') AS article_title
|
||||
FROM publish_records pr
|
||||
LEFT JOIN media_platforms mp
|
||||
ON mp.platform_id = pr.platform_id
|
||||
LEFT JOIN articles a
|
||||
ON a.id = pr.article_id AND a.tenant_id = pr.tenant_id
|
||||
LEFT JOIN article_versions av
|
||||
ON av.id = a.current_version_id
|
||||
WHERE pr.id = $1 AND pr.tenant_id = $2
|
||||
`, publishRecordID, task.TenantID).Scan(&platformID, &platformName, &articleTitle); err == nil {
|
||||
outcome.ArticleAlias = &monitoringArticleAliasInput{
|
||||
TenantID: task.TenantID,
|
||||
ArticleID: articleID,
|
||||
PublishRecordID: publishRecordID,
|
||||
PlatformID: platformID,
|
||||
PlatformName: platformName,
|
||||
ArticleTitle: articleTitle,
|
||||
ExternalArticleID: externalArticleID,
|
||||
ExternalArticleURL: externalArticleURL,
|
||||
ExternalManageURL: externalManageURL,
|
||||
}
|
||||
}
|
||||
}
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
func loadPlatformAccountSeedByDesktopID(
|
||||
|
||||
@@ -59,6 +59,21 @@ func (h *MonitoringHandler) DashboardComposite(c *gin.Context) {
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *MonitoringHandler) CitationSummary(c *gin.Context) {
|
||||
days, err := parseOptionalInt(c.Query("days"))
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40031, "invalid_days", "days must be a number"))
|
||||
return
|
||||
}
|
||||
|
||||
data, svcErr := h.svc.CitationSummary(c.Request.Context(), days)
|
||||
if svcErr != nil {
|
||||
response.Error(c, svcErr)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *MonitoringHandler) QuestionDetail(c *gin.Context) {
|
||||
brandID, err := strconv.ParseInt(c.Param("brand_id"), 10, 64)
|
||||
if err != nil {
|
||||
|
||||
@@ -183,6 +183,7 @@ func RegisterRoutes(app *bootstrap.App) {
|
||||
monitoring := tenantProtected.Group("/monitoring")
|
||||
monitoringHandler := NewMonitoringHandler(app)
|
||||
monitoring.GET("/dashboard/composite", monitoringHandler.DashboardComposite)
|
||||
monitoring.GET("/citation-summary", monitoringHandler.CitationSummary)
|
||||
monitoring.GET("/brands/:brand_id/questions/:question_id/detail", monitoringHandler.QuestionDetail)
|
||||
monitoring.POST("/brands/:brand_id/collect-now", monitoringHandler.CollectNow)
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
DROP INDEX IF EXISTS idx_article_url_alias_site_key;
|
||||
DROP INDEX IF EXISTS idx_article_url_alias_domain;
|
||||
|
||||
ALTER TABLE monitoring_article_url_aliases
|
||||
DROP COLUMN IF EXISTS site_key,
|
||||
DROP COLUMN IF EXISTS registrable_domain,
|
||||
DROP COLUMN IF EXISTS host;
|
||||
@@ -0,0 +1,27 @@
|
||||
ALTER TABLE monitoring_article_url_aliases
|
||||
ADD COLUMN IF NOT EXISTS host VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS registrable_domain VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS site_key VARCHAR(255);
|
||||
|
||||
WITH parsed_aliases AS (
|
||||
SELECT
|
||||
id,
|
||||
LOWER(SPLIT_PART(SUBSTRING(original_url FROM '^[a-zA-Z][a-zA-Z0-9+.-]*://([^/?#]+)'), ':', 1)) AS parsed_host
|
||||
FROM monitoring_article_url_aliases
|
||||
)
|
||||
UPDATE monitoring_article_url_aliases alias
|
||||
SET host = COALESCE(NULLIF(alias.host, ''), parsed_aliases.parsed_host),
|
||||
registrable_domain = COALESCE(NULLIF(alias.registrable_domain, ''), parsed_aliases.parsed_host),
|
||||
site_key = COALESCE(NULLIF(alias.site_key, ''), parsed_aliases.parsed_host)
|
||||
FROM parsed_aliases
|
||||
WHERE alias.id = parsed_aliases.id
|
||||
AND parsed_aliases.parsed_host IS NOT NULL
|
||||
AND parsed_aliases.parsed_host <> '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_article_url_alias_domain
|
||||
ON monitoring_article_url_aliases(tenant_id, registrable_domain)
|
||||
WHERE registrable_domain IS NOT NULL AND registrable_domain <> '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_article_url_alias_site_key
|
||||
ON monitoring_article_url_aliases(tenant_id, site_key)
|
||||
WHERE site_key IS NOT NULL AND site_key <> '';
|
||||
Reference in New Issue
Block a user