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:
2026-04-30 17:12:09 +08:00
parent 6e6e19cccb
commit fb166fa9fe
15 changed files with 836 additions and 143 deletions
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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: "渠道名称",
+8
View File
@@ -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,
+91 -28
View File
@@ -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;
}
+17
View File
@@ -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,
&registrableDomain,
&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 := "搜索网页结果"
+215 -86
View File
@@ -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 <> '';