feat(monitoring): match citations by canonical link and scope summary by brand/keyword/platform/date

Replace the domain+title fuzzy alias scoring with a canonical
candidate-key index built from published-link aliases — only exact
matches now resolve to a SaaS source, eliminating false positives
across articles sharing a host (e.g. m.163.com vs www.163.com).

Surface the summary scoping that already existed on loadCitationRanking
/ loadCitedArticles by accepting brand_id, keyword_id, ai_platform_id
and business_date on CitationSummary, plumbing them through the handler
and admin tracking view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 11:46:17 +08:00
parent 2436f50c1f
commit b345ee26e4
10 changed files with 592 additions and 307 deletions
+1 -1
View File
@@ -432,7 +432,7 @@ const enUS = {
backToQuestions: "Back to questions", backToQuestions: "Back to questions",
citationRanking: "Citation Ranking", citationRanking: "Citation Ranking",
citationRankingTitle: "Citation Ranking", citationRankingTitle: "Citation Ranking",
citationRankingHint: "Attribute SaaS-published content by registrable domain across model citation sources.", citationRankingHint: "Attribute SaaS-published content by published-link identity across model citation sources.",
citationWindow7: "Last 7 days", citationWindow7: "Last 7 days",
citationWindow30: "Last 30 days", citationWindow30: "Last 30 days",
citationWindowLabel: "Last {days} days", citationWindowLabel: "Last {days} days",
+1 -1
View File
@@ -433,7 +433,7 @@ const zhCN = {
backToQuestions: "返回问题列表", backToQuestions: "返回问题列表",
citationRanking: "Citation Ranking", citationRanking: "Citation Ranking",
citationRankingTitle: "引用排行", citationRankingTitle: "引用排行",
citationRankingHint: "按主体域名归因 SaaS 发文在模型引用来源中的出现次数与占比。", citationRankingHint: "按发布外链主体归因 SaaS 发文在模型引用来源中的出现次数与占比。",
citationWindow7: "近 7 天", citationWindow7: "近 7 天",
citationWindow30: "近 30 天", citationWindow30: "近 30 天",
citationWindowLabel: "近 {days} 天", citationWindowLabel: "近 {days} 天",
+4
View File
@@ -1078,6 +1078,10 @@ export const monitoringApi = {
}, },
citationSummary(params: { citationSummary(params: {
days?: number; days?: number;
brand_id?: number;
keyword_id?: number | null;
business_date?: string;
ai_platform_id?: string;
}) { }) {
return apiClient.get<MonitoringCitationSummaryResponse>("/api/tenant/monitoring/citation-summary", { return apiClient.get<MonitoringCitationSummaryResponse>("/api/tenant/monitoring/citation-summary", {
params, params,
@@ -251,10 +251,19 @@ const citationSummaryQuery = useQuery({
queryKey: computed(() => [ queryKey: computed(() => [
"tracking", "tracking",
"citation-summary", "citation-summary",
selectedBrandId.value,
selectedKeywordId.value,
selectedPlatformId.value,
selectedBusinessDate.value,
selectedCitationWindowDays.value, selectedCitationWindowDays.value,
]), ]),
enabled: computed(() => Boolean(selectedBrandId.value)),
queryFn: () => queryFn: () =>
monitoringApi.citationSummary({ monitoringApi.citationSummary({
brand_id: selectedBrandId.value ?? undefined,
keyword_id: selectedKeywordId.value,
business_date: selectedBusinessDate.value,
ai_platform_id: selectedPlatformId.value !== "all" ? selectedPlatformId.value : undefined,
days: selectedCitationWindowDays.value, days: selectedCitationWindowDays.value,
}), }),
}); });
@@ -284,6 +284,7 @@ type monitoringAliasResolution struct {
type monitoringAliasLookupInput struct { type monitoringAliasLookupInput struct {
NormalizedURL string NormalizedURL string
MatchKeys []string
RegistrableDomain string RegistrableDomain string
SiteKey string SiteKey string
Host string Host string
@@ -293,6 +294,7 @@ type monitoringAliasLookupInput struct {
type monitoringAliasCandidate struct { type monitoringAliasCandidate struct {
NormalizedURL string NormalizedURL string
MatchKeys []string
ArticleID *int64 ArticleID *int64
PublishRecordID *int64 PublishRecordID *int64
RegistrableDomain string RegistrableDomain string
@@ -2444,6 +2446,7 @@ func (s *MonitoringCallbackService) buildCitationFacts(ctx context.Context, tx p
} }
lookupInputs = append(lookupInputs, monitoringAliasLookupInput{ lookupInputs = append(lookupInputs, monitoringAliasLookupInput{
NormalizedURL: key, NormalizedURL: key,
MatchKeys: monitoringCitationCandidateKeys(item.URL, key),
RegistrableDomain: strings.ToLower(strings.TrimSpace(registrableDomain)), RegistrableDomain: strings.ToLower(strings.TrimSpace(registrableDomain)),
SiteKey: strings.ToLower(strings.TrimSpace(siteKey)), SiteKey: strings.ToLower(strings.TrimSpace(siteKey)),
Host: strings.ToLower(strings.TrimSpace(host)), Host: strings.ToLower(strings.TrimSpace(host)),
@@ -2557,6 +2560,7 @@ func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx
candidate.Host = strings.ToLower(strings.TrimSpace(nullableStringText(host))) candidate.Host = strings.ToLower(strings.TrimSpace(nullableStringText(host)))
candidate.LastPathSegment = strings.TrimSpace(nullableStringText(lastPathSegment)) candidate.LastPathSegment = strings.TrimSpace(nullableStringText(lastPathSegment))
candidate.TitleKey = normalizeMonitoringAliasTitle(nullableStringText(articleTitle)) candidate.TitleKey = normalizeMonitoringAliasTitle(nullableStringText(articleTitle))
candidate.MatchKeys = monitoringCitationCandidateKeys(candidate.NormalizedURL, candidate.NormalizedURL)
candidates = append(candidates, candidate) candidates = append(candidates, candidate)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@@ -2567,11 +2571,7 @@ func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx
bestScore := -1 bestScore := -1
bestAmbiguous := false bestAmbiguous := false
var best monitoringAliasCandidate var best monitoringAliasCandidate
domainMatchedArticleIDs := map[int64]struct{}{}
for _, candidate := range candidates { for _, candidate := range candidates {
if monitoringAliasDomainMatches(input, candidate) && candidate.ArticleID != nil {
domainMatchedArticleIDs[*candidate.ArticleID] = struct{}{}
}
score := scoreMonitoringAliasCandidate(input, candidate) score := scoreMonitoringAliasCandidate(input, candidate)
if score < 0 { if score < 0 {
continue continue
@@ -2586,27 +2586,16 @@ func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx
bestAmbiguous = true bestAmbiguous = true
} }
} }
if bestScore == 40 && len(domainMatchedArticleIDs) == 1 {
bestScore = 60
}
if bestScore < 60 || bestAmbiguous { if bestScore < 100 || bestAmbiguous {
continue continue
} }
confidence := "medium"
status := "domain_matched"
if bestScore >= 100 {
confidence = "high"
status = "resolved"
} else if bestScore < 80 {
confidence = "low"
}
result[input.NormalizedURL] = monitoringAliasResolution{ result[input.NormalizedURL] = monitoringAliasResolution{
ArticleID: cloneInt64(best.ArticleID), ArticleID: cloneInt64(best.ArticleID),
PublishRecordID: cloneInt64(best.PublishRecordID), PublishRecordID: cloneInt64(best.PublishRecordID),
NormalizedURLKey: best.NormalizedURL, NormalizedURLKey: best.NormalizedURL,
ResolutionStatus: status, ResolutionStatus: "resolved",
ResolutionConfidence: confidence, ResolutionConfidence: "high",
} }
} }
@@ -2618,23 +2607,19 @@ func scoreMonitoringAliasCandidate(input monitoringAliasLookupInput, candidate m
return 100 return 100
} }
if !monitoringAliasDomainMatches(input, candidate) { for _, inputKey := range input.MatchKeys {
return -1 inputKey = strings.TrimSpace(inputKey)
} if inputKey == "" {
continue
score := 40 }
if input.LastPathSegment != nil && strings.TrimSpace(*input.LastPathSegment) != "" && candidate.LastPathSegment == strings.TrimSpace(*input.LastPathSegment) { for _, candidateKey := range candidate.MatchKeys {
score += 30 if inputKey == strings.TrimSpace(candidateKey) && candidateKey != "" {
} return 100
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
return -1
} }
func monitoringAliasDomainMatches(input monitoringAliasLookupInput, candidate monitoringAliasCandidate) bool { func monitoringAliasDomainMatches(input monitoringAliasLookupInput, candidate monitoringAliasCandidate) bool {
@@ -425,23 +425,33 @@ func TestBuildMonitoringCitationFactCanDisableContentResolution(t *testing.T) {
} }
} }
func TestScoreMonitoringAliasCandidateUsesDomainWithTitleEvidence(t *testing.T) { func TestScoreMonitoringAliasCandidateUsesCanonicalArticleKey(t *testing.T) {
lastPath := "article-42"
input := monitoringAliasLookupInput{ input := monitoringAliasLookupInput{
NormalizedURL: "https://m.example.com/article-42", NormalizedURL: "https://m.163.com/dy/article/KRPRE8DJ0556MCR0.html",
RegistrableDomain: "example.com", MatchKeys: monitoringCitationCandidateKeys("https://m.163.com/dy/article/KRPRE8DJ0556MCR0.html", ""),
LastPathSegment: &lastPath,
TitleKey: normalizeMonitoringAliasTitle("北京口腔医院全面测评"),
} }
candidate := monitoringAliasCandidate{ candidate := monitoringAliasCandidate{
NormalizedURL: "https://www.example.com/posts/article-42", NormalizedURL: "https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html",
RegistrableDomain: "example.com", MatchKeys: monitoringCitationCandidateKeys("https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html", ""),
LastPathSegment: "article-42",
TitleKey: normalizeMonitoringAliasTitle("北京口腔医院全面测评"),
} }
if score := scoreMonitoringAliasCandidate(input, candidate); score < 90 { if score := scoreMonitoringAliasCandidate(input, candidate); score != 100 {
t.Fatalf("scoreMonitoringAliasCandidate() = %d, want strong domain/title match", score) t.Fatalf("scoreMonitoringAliasCandidate() = %d, want canonical URL match", score)
}
}
func TestScoreMonitoringAliasCandidateRejectsSameDomainDifferentArticle(t *testing.T) {
input := monitoringAliasLookupInput{
NormalizedURL: "https://www.163.com/dy/article/KIN5DDMK0556GQZ8.html",
MatchKeys: monitoringCitationCandidateKeys("https://www.163.com/dy/article/KIN5DDMK0556GQZ8.html", ""),
}
candidate := monitoringAliasCandidate{
NormalizedURL: "https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html",
MatchKeys: monitoringCitationCandidateKeys("https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html", ""),
}
if score := scoreMonitoringAliasCandidate(input, candidate); score != -1 {
t.Fatalf("scoreMonitoringAliasCandidate() = %d, want no same-domain match", score)
} }
} }
@@ -0,0 +1,251 @@
package app
import (
"context"
"database/sql"
"net"
"net/url"
"sort"
"strings"
"github.com/jackc/pgx/v5"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
type monitoringPublishedAlias struct {
ArticleID int64
PublishRecordID *int64
ArticleTitle string
PublishPlatform string
Ambiguous bool
}
type monitoringPublishedAliasIndex map[string]monitoringPublishedAlias
type monitoringAliasQuerier interface {
Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error)
}
func loadMonitoringPublishedAliasIndex(ctx context.Context, q monitoringAliasQuerier, tenantID int64) (monitoringPublishedAliasIndex, error) {
rows, err := q.Query(ctx, `
SELECT
article_id,
publish_record_id,
COALESCE(article_title_snapshot, '未命名文章') AS article_title,
COALESCE(publish_platform_name_snapshot, '已发布内容') AS publish_platform,
original_url,
normalized_url
FROM monitoring_article_url_aliases
WHERE tenant_id = $1
AND confidence = 'high'
ORDER BY updated_at DESC, id DESC
`, tenantID)
if err != nil {
return nil, responseInternalAliasLookupError()
}
defer rows.Close()
index := make(monitoringPublishedAliasIndex)
for rows.Next() {
var item monitoringPublishedAlias
var originalURL string
var normalizedURL string
var publishRecordID sql.NullInt64
if scanErr := rows.Scan(&item.ArticleID, &publishRecordID, &item.ArticleTitle, &item.PublishPlatform, &originalURL, &normalizedURL); scanErr != nil {
return nil, responseInternalAliasScanError()
}
item.PublishRecordID = nullableInt64Value(publishRecordID)
for _, key := range monitoringCitationCandidateKeys(originalURL, normalizedURL) {
existing, exists := index[key]
if exists && existing.ArticleID != item.ArticleID {
existing.Ambiguous = true
index[key] = existing
continue
}
if !exists {
index[key] = item
}
}
}
if err := rows.Err(); err != nil {
return nil, responseInternalAliasScanError()
}
return index, nil
}
func (index monitoringPublishedAliasIndex) Match(rawURL, normalizedURL string) (monitoringPublishedAlias, bool) {
for _, key := range monitoringCitationCandidateKeys(rawURL, normalizedURL) {
item, ok := index[key]
if ok && !item.Ambiguous {
return item, true
}
}
return monitoringPublishedAlias{}, false
}
func monitoringCitationCandidateKeys(rawURL, normalizedURL string) []string {
keys := []string{
monitoringCitationURLMatchKey(rawURL),
monitoringCitationURLMatchKey(normalizedURL),
}
result := make([]string, 0, len(keys))
seen := make(map[string]struct{}, len(keys))
for _, key := range keys {
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result = append(result, key)
}
return result
}
func monitoringCitationURLMatchKey(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Hostname() == "" {
return ""
}
host := monitoringCanonicalCitationHost(parsed.Hostname())
if host == "" {
return ""
}
if strings.HasSuffix(host, "163.com") {
if id := monitoringNeteaseArticleID(trimmed); id != "" {
return "163.com/dy/article/" + strings.ToLower(id)
}
}
if host == "baijiahao.baidu.com" {
if id := extractBaijiahaoArticleIDFromURL(&trimmed); id != "" {
return host + "/s?id=" + strings.ToLower(id)
}
}
path := strings.TrimRight(parsed.EscapedPath(), "/")
if path == "" {
path = "/"
}
if host == "mp.weixin.qq.com" {
if strings.HasPrefix(path, "/s/") && len(path) > len("/s/") {
return host + path
}
if query := monitoringCanonicalQuery(parsed, []string{"__biz", "mid", "idx", "sn"}); query != "" {
return host + path + "?" + query
}
return ""
}
if path == "/" {
if query := monitoringCanonicalQuery(parsed, monitoringGenericContentQueryKeys()); query != "" {
return host + path + "?" + query
}
return ""
}
if query := monitoringCanonicalQuery(parsed, monitoringGenericContentQueryKeys()); query != "" {
return host + path + "?" + query
}
return host + path
}
func monitoringCanonicalCitationHost(value string) string {
host := strings.ToLower(strings.TrimSpace(value))
if host == "" {
return ""
}
if withoutPort, _, err := net.SplitHostPort(host); err == nil {
host = withoutPort
}
host = strings.Trim(host, ".")
parts := strings.Split(host, ".")
if len(parts) <= 2 {
return host
}
switch parts[0] {
case "www", "m", "wap", "mobile", "3g", "amp":
return strings.Join(parts[1:], ".")
default:
return host
}
}
func monitoringCanonicalQuery(parsed *url.URL, allowedKeys []string) string {
if parsed == nil || len(allowedKeys) == 0 {
return ""
}
values := parsed.Query()
pairs := make([]string, 0, len(allowedKeys))
for _, key := range allowedKeys {
value := strings.TrimSpace(values.Get(key))
if value == "" {
continue
}
pairs = append(pairs, url.QueryEscape(key)+"="+url.QueryEscape(value))
}
sort.Strings(pairs)
return strings.Join(pairs, "&")
}
func monitoringGenericContentQueryKeys() []string {
return []string{"id", "article_id", "articleId", "docid", "docId", "postId"}
}
func monitoringNeteaseArticleID(rawURL string) string {
if id := extractWangyihaoArticleIDFromURL(&rawURL); id != "" {
return id
}
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return ""
}
segments := strings.Split(strings.Trim(parsed.Path, "/"), "/")
for index := len(segments) - 1; index >= 0; index-- {
id := strings.TrimSuffix(strings.TrimSpace(segments[index]), ".html")
if monitoringLooksLikeNeteaseArticleID(id) {
return id
}
}
return ""
}
func monitoringLooksLikeNeteaseArticleID(value string) bool {
if len(value) < 8 || len(value) > 32 {
return false
}
hasDigit := false
hasLetter := false
for _, r := range value {
switch {
case r >= '0' && r <= '9':
hasDigit = true
case r >= 'A' && r <= 'Z':
hasLetter = true
case r >= 'a' && r <= 'z':
hasLetter = true
default:
return false
}
}
return hasDigit && hasLetter
}
func responseInternalAliasLookupError() error {
return response.ErrInternal(50041, "alias_lookup_failed", "failed to resolve article aliases")
}
func responseInternalAliasScanError() error {
return response.ErrInternal(50041, "alias_scan_failed", "failed to parse article alias resolution")
}
+261 -259
View File
@@ -414,29 +414,55 @@ func (s *MonitoringService) DashboardComposite(
func (s *MonitoringService) CitationSummary( func (s *MonitoringService) CitationSummary(
ctx context.Context, ctx context.Context,
days int, days int,
brandID int64,
keywordID *int64,
businessDate string,
aiPlatformID *string,
) (*MonitoringCitationSummaryResponse, error) { ) (*MonitoringCitationSummaryResponse, error) {
actor := auth.MustActor(ctx) actor := auth.MustActor(ctx)
workspaceID := auth.CurrentWorkspaceID(ctx) workspaceID := auth.CurrentWorkspaceID(ctx)
var questionIDs []int64
if brandID != 0 {
brand, err := s.resolveBrand(ctx, actor.TenantID, brandID)
if err != nil {
return nil, err
}
if keywordID != nil {
if err := s.ensureKeywordBelongsToBrand(ctx, actor.TenantID, brand.ID, *keywordID); err != nil {
return nil, err
}
}
configuredQuestions, err := s.loadConfiguredQuestions(ctx, actor.TenantID, brand.ID, keywordID)
if err != nil {
return nil, err
}
questionIDs = configuredQuestionIDs(configuredQuestions)
}
quota, err := s.loadQuota(ctx, actor, workspaceID) quota, err := s.loadQuota(ctx, actor, workspaceID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
citationWindowDays := normalizeCitationWindowDays(days) citationWindowDays := normalizeCitationWindowDays(days)
startDate, endDate := trackingDateWindow(citationWindowDays) startDate, endDate, err := resolveDashboardDateWindow(citationWindowDays, businessDate)
if err != nil {
return nil, err
}
accessStates, err := s.loadAccessStates(ctx, actor.TenantID, workspaceID, quota.PrimaryClientID, endDate) accessStates, err := s.loadAccessStates(ctx, actor.TenantID, workspaceID, quota.PrimaryClientID, endDate)
if err != nil { if err != nil {
return nil, err return nil, err
} }
citationRanking, err := s.loadCitationRanking(ctx, actor.TenantID, 0, nil, startDate, endDate, accessStates, nil) selectedPlatformID := normalizedOptionalMonitoringPlatformPointer(aiPlatformID)
citationRanking, err := s.loadCitationRanking(ctx, actor.TenantID, brandID, questionIDs, startDate, endDate, accessStates, selectedPlatformID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
citedArticles, err := s.loadCitedArticles(ctx, actor.TenantID, 0, nil, startDate, endDate, nil) citedArticles, err := s.loadCitedArticles(ctx, actor.TenantID, brandID, questionIDs, startDate, endDate, selectedPlatformID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -2481,98 +2507,10 @@ func (s *MonitoringService) loadCitationRanking(
} }
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID) platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
rows, err := s.monitoringPool.Query(ctx, ` aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
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 ($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_sources AS (
SELECT
r.ai_platform_id,
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 ($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))
),
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.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 { if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load citation ranking") return nil, err
} }
defer rows.Close()
type citationRankingAggregate struct { type citationRankingAggregate struct {
CitedAnswerCount int64 CitedAnswerCount int64
@@ -2583,14 +2521,29 @@ func (s *MonitoringService) loadCitationRanking(
} }
aggregates := make(map[string]citationRankingAggregate) aggregates := make(map[string]citationRankingAggregate)
rows, err := s.monitoringPool.Query(ctx, `
SELECT
r.ai_platform_id,
COUNT(*) AS sample_count
FROM question_monitor_runs r
WHERE r.tenant_id = $1
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
`, 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")
}
defer rows.Close()
for rows.Next() { for rows.Next() {
var platformID string var platformID string
var sampleCount int64 var sampleCount int64
var citedAnswerCount int64 if scanErr := rows.Scan(&platformID, &sampleCount); scanErr != nil {
var citedArticleCount int64
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") return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking")
} }
platformID = normalizeMonitoringPlatformID(platformID) platformID = normalizeMonitoringPlatformID(platformID)
@@ -2598,16 +2551,80 @@ func (s *MonitoringService) loadCitationRanking(
continue continue
} }
item := aggregates[platformID] item := aggregates[platformID]
item.CitedAnswerCount += citedAnswerCount
item.CitedArticleCount += citedArticleCount
item.CitationSourceCount += citationSourceCount
item.SaaSSourceCount += saasSourceCount
item.SampleCount += sampleCount item.SampleCount += sampleCount
aggregates[platformID] = item aggregates[platformID] = item
} }
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate citation ranking")
}
citedRuns := make(map[string]map[int64]struct{})
citedArticles := make(map[string]map[int64]struct{})
rows, err = s.monitoringPool.Query(ctx, `
SELECT
r.ai_platform_id,
cf.run_id,
cf.cited_url,
cf.normalized_url
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 ($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))
`, 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")
}
defer rows.Close()
for rows.Next() {
var platformID string
var runID int64
var citedURL string
var normalizedURL string
if scanErr := rows.Scan(&platformID, &runID, &citedURL, &normalizedURL); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking")
}
platformID = normalizeMonitoringPlatformID(platformID)
if platformID == "" {
continue
}
item := aggregates[platformID]
item.CitationSourceCount++
if platformID != "qwen" {
if alias, ok := aliasIndex.Match(citedURL, normalizedURL); ok {
item.SaaSSourceCount++
if _, exists := citedRuns[platformID]; !exists {
citedRuns[platformID] = make(map[int64]struct{})
}
citedRuns[platformID][runID] = struct{}{}
if _, exists := citedArticles[platformID]; !exists {
citedArticles[platformID] = make(map[int64]struct{})
}
citedArticles[platformID][alias.ArticleID] = struct{}{}
}
}
aggregates[platformID] = item
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate citation ranking")
}
for platformID, item := range aggregates {
item.CitedAnswerCount = int64(len(citedRuns[platformID]))
item.CitedArticleCount = int64(len(citedArticles[platformID]))
aggregates[platformID] = item
}
items := make([]MonitoringCitationRanking, 0, len(aggregates)) items := make([]MonitoringCitationRanking, 0, len(aggregates))
for platformID, aggregate := range aggregates { for platformID, aggregate := range aggregates {
if aggregate.SaaSSourceCount == 0 {
continue
}
items = append(items, MonitoringCitationRanking{ items = append(items, MonitoringCitationRanking{
AIPlatformID: platformID, AIPlatformID: platformID,
PlatformName: platformDisplayName(platformID), PlatformName: platformDisplayName(platformID),
@@ -2648,6 +2665,11 @@ func (s *MonitoringService) loadCitedArticles(
} }
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID) platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
if err != nil {
return nil, err
}
var totalSampleCount int64 var totalSampleCount int64
if err := s.monitoringPool.QueryRow(ctx, ` if err := s.monitoringPool.QueryRow(ctx, `
SELECT COUNT(*) SELECT COUNT(*)
@@ -2664,78 +2686,78 @@ func (s *MonitoringService) loadCitedArticles(
} }
rows, err := s.monitoringPool.Query(ctx, ` 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 SELECT
cf.article_id, cf.cited_url,
cf.article_title, cf.normalized_url
cf.publish_platform, FROM monitoring_citation_facts cf
cf.citation_count, JOIN question_monitor_runs r
t.total_article_citation_count ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id
FROM article_counts cf WHERE cf.tenant_id = $1
CROSS JOIN totals t AND ($2::bigint = 0 OR cf.brand_id = $2)
ORDER BY citation_count DESC, cf.article_id ASC AND r.ai_platform_id <> 'qwen'
LIMIT 10 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))
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs)) `, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs))
if err != nil { if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load cited articles") return nil, response.ErrInternal(50041, "query_failed", "failed to load cited articles")
} }
defer rows.Close() defer rows.Close()
items := make([]MonitoringCitedArticle, 0) type citedArticleAggregate struct {
ArticleID int64
ArticleTitle string
PublishPlatform string
CitationCount int64
}
aggregates := make(map[int64]citedArticleAggregate)
var totalArticleCitationCount int64
for rows.Next() { for rows.Next() {
var articleID int64 var citedURL string
var title string var normalizedURL string
var publishPlatform string if scanErr := rows.Scan(&citedURL, &normalizedURL); scanErr != nil {
var citationCount int64
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") return nil, response.ErrInternal(50041, "scan_failed", "failed to parse cited articles")
} }
alias, ok := aliasIndex.Match(citedURL, normalizedURL)
if !ok {
continue
}
item := aggregates[alias.ArticleID]
if item.ArticleID == 0 {
item.ArticleID = alias.ArticleID
item.ArticleTitle = alias.ArticleTitle
item.PublishPlatform = alias.PublishPlatform
}
item.CitationCount++
totalArticleCitationCount++
aggregates[alias.ArticleID] = item
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate cited articles")
}
items := make([]MonitoringCitedArticle, 0, len(aggregates))
for _, item := range aggregates {
items = append(items, MonitoringCitedArticle{ items = append(items, MonitoringCitedArticle{
ArticleID: articleID, ArticleID: item.ArticleID,
ArticleTitle: title, ArticleTitle: item.ArticleTitle,
PublishPlatform: publishPlatform, PublishPlatform: item.PublishPlatform,
CitationCount: citationCount, CitationCount: item.CitationCount,
CitationRate: divideAsPointer(citationCount, totalSampleCount), CitationRate: divideAsPointer(item.CitationCount, totalSampleCount),
SourceShare: divideAsPointer(citationCount, totalArticleCitationCount), SourceShare: divideAsPointer(item.CitationCount, totalArticleCitationCount),
}) })
} }
sort.Slice(items, func(left, right int) bool {
if items[left].CitationCount != items[right].CitationCount {
return items[left].CitationCount > items[right].CitationCount
}
return items[left].ArticleID < items[right].ArticleID
})
if len(items) > 10 {
items = items[:10]
}
return items, nil return items, nil
} }
@@ -2923,6 +2945,11 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
return result, nil return result, nil
} }
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
if err != nil {
return nil, err
}
rows, err := s.monitoringPool.Query(ctx, ` rows, err := s.monitoringPool.Query(ctx, `
WITH filtered_citations AS ( WITH filtered_citations AS (
SELECT SELECT
@@ -2930,10 +2957,10 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
cf.run_id, cf.run_id,
cf.cited_url, cf.cited_url,
cf.cited_title, cf.cited_title,
cf.normalized_url,
cf.host, cf.host,
cf.registrable_domain, cf.registrable_domain,
cf.site_key, cf.site_key,
cf.article_id,
cf.resolution_status, cf.resolution_status,
cf.resolution_confidence cf.resolution_confidence
FROM monitoring_citation_facts cf FROM monitoring_citation_facts cf
@@ -2979,10 +3006,9 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
fc.run_id, fc.run_id,
fc.cited_url, fc.cited_url,
fc.cited_title, fc.cited_title,
fc.normalized_url,
COALESCE(dm.mapped_site_name, fc.site_key, fc.registrable_domain) AS site_name, COALESCE(dm.mapped_site_name, fc.site_key, fc.registrable_domain) AS site_name,
COALESCE(dm.mapped_site_key, fc.site_key) AS site_key, COALESCE(dm.mapped_site_key, fc.site_key) AS site_key,
fc.article_id,
alias.article_title_snapshot AS article_title,
fc.resolution_status, fc.resolution_status,
fc.resolution_confidence fc.resolution_confidence
FROM filtered_citations fc FROM filtered_citations fc
@@ -2990,8 +3016,6 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
ON dm.host = fc.host ON dm.host = fc.host
AND dm.registrable_domain = fc.registrable_domain AND dm.registrable_domain = fc.registrable_domain
AND dm.site_key = fc.site_key AND dm.site_key = fc.site_key
LEFT JOIN monitoring_article_url_aliases alias
ON alias.tenant_id = $1 AND alias.article_id = fc.article_id
ORDER BY fc.run_id ASC, fc.id ASC ORDER BY fc.run_id ASC, fc.id ASC
`, tenantID, runIDs) `, tenantID, runIDs)
if err != nil { if err != nil {
@@ -3003,21 +3027,26 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
var runID int64 var runID int64
var citedURL string var citedURL string
var citedTitle sql.NullString var citedTitle sql.NullString
var normalizedURL string
var siteName string var siteName string
var siteKey string var siteKey string
var articleID sql.NullInt64
var articleTitle sql.NullString
var resolutionStatus string var resolutionStatus string
var resolutionConfidence string var resolutionConfidence string
if scanErr := rows.Scan(&runID, &citedURL, &citedTitle, &siteName, &siteKey, &articleID, &articleTitle, &resolutionStatus, &resolutionConfidence); scanErr != nil { if scanErr := rows.Scan(&runID, &citedURL, &citedTitle, &normalizedURL, &siteName, &siteKey, &resolutionStatus, &resolutionConfidence); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citations") return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citations")
} }
siteName = repairMonitoringMojibake(siteName) siteName = repairMonitoringMojibake(siteName)
if citedTitle.Valid { if citedTitle.Valid {
citedTitle.String = repairMonitoringMojibake(citedTitle.String) citedTitle.String = repairMonitoringMojibake(citedTitle.String)
} }
if articleTitle.Valid { var articleID *int64
articleTitle.String = repairMonitoringMojibake(articleTitle.String) var articleTitle *string
if alias, ok := aliasIndex.Match(citedURL, normalizedURL); ok {
articleID = &alias.ArticleID
title := repairMonitoringMojibake(alias.ArticleTitle)
articleTitle = &title
resolutionStatus = "resolved"
resolutionConfidence = "high"
} }
result[runID] = append(result[runID], MonitoringQuestionDetailCitation{ result[runID] = append(result[runID], MonitoringQuestionDetailCitation{
CitedURL: citedURL, CitedURL: citedURL,
@@ -3025,12 +3054,15 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
SiteName: siteName, SiteName: siteName,
SiteKey: siteKey, SiteKey: siteKey,
FaviconURL: faviconURL(siteKey), FaviconURL: faviconURL(siteKey),
ArticleID: nullableInt64Value(articleID), ArticleID: articleID,
ArticleTitle: nullableStringValue(articleTitle), ArticleTitle: articleTitle,
ResolutionStatus: resolutionStatus, ResolutionStatus: resolutionStatus,
ResolutionConfidence: resolutionConfidence, ResolutionConfidence: resolutionConfidence,
}) })
} }
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate citations")
}
return result, nil return result, nil
} }
@@ -3040,15 +3072,21 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
return []MonitoringQuestionCitationStats{}, nil return []MonitoringQuestionCitationStats{}, nil
} }
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
if err != nil {
return nil, err
}
rows, err := s.monitoringPool.Query(ctx, ` rows, err := s.monitoringPool.Query(ctx, `
WITH filtered_facts AS ( WITH filtered_facts AS (
SELECT SELECT
r.ai_platform_id, r.ai_platform_id,
cf.id, cf.id,
cf.cited_url,
cf.normalized_url,
cf.host, cf.host,
cf.registrable_domain, cf.registrable_domain,
cf.site_key, cf.site_key
cf.article_id
FROM question_monitor_runs r FROM question_monitor_runs r
JOIN monitoring_citation_facts cf JOIN monitoring_citation_facts cf
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
@@ -3090,49 +3128,20 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
ORDER BY LENGTH(mapping.registrable_domain) DESC, mapping.id DESC ORDER BY LENGTH(mapping.registrable_domain) DESC, mapping.id DESC
LIMIT 1 LIMIT 1
) dm ON TRUE ) dm ON TRUE
),
grouped AS (
SELECT
ff.ai_platform_id,
COALESCE(dm.mapped_site_name, ff.site_key, ff.registrable_domain) AS site_name,
COALESCE(dm.mapped_site_key, ff.site_key) AS site_key,
COALESCE(dm.mapped_domain, ff.site_key, ff.registrable_domain) AS site_domain,
COUNT(ff.id) AS citation_count,
COUNT(*) FILTER (WHERE ff.article_id IS NOT NULL AND ff.ai_platform_id <> 'qwen') AS content_citation_count
FROM filtered_facts ff
LEFT JOIN domain_mappings dm
ON dm.host = ff.host
AND dm.registrable_domain = ff.registrable_domain
AND dm.site_key = ff.site_key
GROUP BY
ff.ai_platform_id,
COALESCE(dm.mapped_site_name, ff.site_key, ff.registrable_domain),
COALESCE(dm.mapped_site_key, ff.site_key),
COALESCE(dm.mapped_domain, ff.site_key, ff.registrable_domain)
),
totals AS (
SELECT
ai_platform_id,
SUM(citation_count) AS total_citation_count
FROM grouped
GROUP BY ai_platform_id
) )
SELECT SELECT
g.ai_platform_id, ff.ai_platform_id,
g.site_name, ff.cited_url,
g.site_key, ff.normalized_url,
g.site_domain, COALESCE(dm.mapped_site_name, ff.site_key, ff.registrable_domain) AS site_name,
g.citation_count, COALESCE(dm.mapped_site_key, ff.site_key) AS site_key,
CASE COALESCE(dm.mapped_domain, ff.site_key, ff.registrable_domain) AS site_domain
WHEN t.total_citation_count > 0 FROM filtered_facts ff
THEN g.citation_count::double precision / t.total_citation_count::double precision LEFT JOIN domain_mappings dm
ELSE NULL ON dm.host = ff.host
END AS citation_rate, AND dm.registrable_domain = ff.registrable_domain
g.content_citation_count AND dm.site_key = ff.site_key
FROM grouped g ORDER BY ff.ai_platform_id ASC, ff.id ASC
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, runIDs) `, tenantID, runIDs)
if err != nil { if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load question citation analysis") return nil, response.ErrInternal(50041, "query_failed", "failed to load question citation analysis")
@@ -3148,12 +3157,12 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
totals := make(map[string]int64) totals := make(map[string]int64)
for rows.Next() { for rows.Next() {
var platformID string var platformID string
var citedURL string
var normalizedURL string
var siteName string var siteName string
var siteKey string var siteKey string
var siteDomain string var siteDomain string
var citationCount int64 if scanErr := rows.Scan(&platformID, &citedURL, &normalizedURL, &siteName, &siteKey, &siteDomain); scanErr != nil {
var contentCitationCount int64
if scanErr := rows.Scan(&platformID, &siteName, &siteKey, &siteDomain, &citationCount, new(sql.NullFloat64), &contentCitationCount); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse question citation analysis") return nil, response.ErrInternal(50041, "scan_failed", "failed to parse question citation analysis")
} }
platformID = normalizeMonitoringPlatformID(platformID) platformID = normalizeMonitoringPlatformID(platformID)
@@ -3174,10 +3183,17 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
SiteDomain: siteDomain, SiteDomain: siteDomain,
} }
} }
item.CitationCount += citationCount item.CitationCount++
item.ContentCitationCount += contentCitationCount if platformID != "qwen" {
if _, ok := aliasIndex.Match(citedURL, normalizedURL); ok {
item.ContentCitationCount++
}
}
aggregates[key] = item aggregates[key] = item
totals[platformID] += citationCount totals[platformID]++
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate question citation analysis")
} }
items := make([]MonitoringQuestionCitationStats, 0, len(aggregates)) items := make([]MonitoringQuestionCitationStats, 0, len(aggregates))
@@ -3206,40 +3222,23 @@ func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, te
return []MonitoringQuestionContentCitation{}, nil return []MonitoringQuestionContentCitation{}, nil
} }
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
if err != nil {
return nil, err
}
rows, err := s.monitoringPool.Query(ctx, ` rows, err := s.monitoringPool.Query(ctx, `
WITH platform_totals AS (
SELECT
r.ai_platform_id,
COUNT(cf.id) AS total_citation_count
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.status = 'succeeded'
AND r.id = ANY($2)
GROUP BY r.ai_platform_id
)
SELECT SELECT
cf.article_id,
COALESCE(MAX(alias.article_title_snapshot), '未命名文章') AS article_title,
COALESCE(MAX(alias.publish_platform_name_snapshot), '已发布内容') AS publish_platform,
r.ai_platform_id, r.ai_platform_id,
COUNT(*) AS citation_count, cf.cited_url,
MAX(pt.total_citation_count) AS total_citation_count cf.normalized_url
FROM question_monitor_runs r FROM question_monitor_runs r
JOIN monitoring_citation_facts cf JOIN monitoring_citation_facts cf
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
JOIN platform_totals pt
ON pt.ai_platform_id = r.ai_platform_id
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 WHERE r.tenant_id = $1
AND r.status = 'succeeded' AND r.status = 'succeeded'
AND cf.article_id IS NOT NULL
AND r.ai_platform_id <> 'qwen' AND r.ai_platform_id <> 'qwen'
AND r.id = ANY($2) AND r.id = ANY($2)
GROUP BY cf.article_id, r.ai_platform_id
ORDER BY citation_count DESC, cf.article_id ASC
`, tenantID, runIDs) `, tenantID, runIDs)
if err != nil { if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load content citations") return nil, response.ErrInternal(50041, "query_failed", "failed to load content citations")
@@ -3254,37 +3253,40 @@ func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, te
aggregates := make(map[contentCitationKey]MonitoringQuestionContentCitation) aggregates := make(map[contentCitationKey]MonitoringQuestionContentCitation)
totals := make(map[string]int64) totals := make(map[string]int64)
for rows.Next() { for rows.Next() {
var articleID int64
var articleTitle string
var publishPlatform string
var platformID string var platformID string
var citationCount int64 var citedURL string
var totalCitationCount int64 var normalizedURL string
if scanErr := rows.Scan(&articleID, &articleTitle, &publishPlatform, &platformID, &citationCount, &totalCitationCount); scanErr != nil { if scanErr := rows.Scan(&platformID, &citedURL, &normalizedURL); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse content citations") return nil, response.ErrInternal(50041, "scan_failed", "failed to parse content citations")
} }
platformID = normalizeMonitoringPlatformID(platformID) platformID = normalizeMonitoringPlatformID(platformID)
if platformID == "" { if platformID == "" {
continue continue
} }
totals[platformID]++
alias, ok := aliasIndex.Match(citedURL, normalizedURL)
if !ok {
continue
}
key := contentCitationKey{ key := contentCitationKey{
PlatformID: platformID, PlatformID: platformID,
ArticleID: articleID, ArticleID: alias.ArticleID,
} }
item := aggregates[key] item := aggregates[key]
if item.ArticleID == 0 { if item.ArticleID == 0 {
item = MonitoringQuestionContentCitation{ item = MonitoringQuestionContentCitation{
ArticleID: articleID, ArticleID: alias.ArticleID,
ArticleTitle: articleTitle, ArticleTitle: alias.ArticleTitle,
PublishPlatform: publishPlatform, PublishPlatform: alias.PublishPlatform,
AIPlatformID: platformID, AIPlatformID: platformID,
PlatformName: platformDisplayName(platformID), PlatformName: platformDisplayName(platformID),
} }
} }
item.CitationCount += citationCount item.CitationCount++
aggregates[key] = item aggregates[key] = item
totals[platformID] += citationCount }
_ = totalCitationCount if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate content citations")
} }
items := make([]MonitoringQuestionContentCitation, 0, len(aggregates)) items := make([]MonitoringQuestionContentCitation, 0, len(aggregates))
@@ -106,6 +106,16 @@ func TestMonitoringAliasOriginalURLUsesWangyihaoPublicURL(t *testing.T) {
} }
} }
func TestMonitoringCitationURLMatchKeyCollapsesNeteaseHostPrefix(t *testing.T) {
left := monitoringCitationURLMatchKey("https://m.163.com/dy/article/KRPRE8DJ0556MCR0.html?from=test")
right := monitoringCitationURLMatchKey("https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html")
const want = "163.com/dy/article/krpre8dj0556mcr0"
if left != want || right != want {
t.Fatalf("monitoringCitationURLMatchKey() = %q and %q; want %q", left, right, want)
}
}
func TestNormalizePublishStatusKeepsPartialSuccess(t *testing.T) { func TestNormalizePublishStatusKeepsPartialSuccess(t *testing.T) {
if got := normalizePublishStatus("partial_success"); got != "partial_success" { if got := normalizePublishStatus("partial_success"); got != "partial_success" {
t.Fatalf("normalizePublishStatus(partial_success) = %q, want partial_success", got) t.Fatalf("normalizePublishStatus(partial_success) = %q, want partial_success", got)
@@ -66,7 +66,21 @@ func (h *MonitoringHandler) CitationSummary(c *gin.Context) {
return return
} }
data, svcErr := h.svc.CitationSummary(c.Request.Context(), days) brandID, err := parseOptionalInt64(c.Query("brand_id"))
if err != nil {
response.Error(c, response.ErrBadRequest(40031, "invalid_brand_id", "brand_id must be a number"))
return
}
keywordID, err := parseOptionalInt64Pointer(c.Query("keyword_id"))
if err != nil {
response.Error(c, response.ErrBadRequest(40031, "invalid_keyword_id", "keyword_id must be a number"))
return
}
aiPlatformID := parseOptionalStringPointer(c.Query("ai_platform_id"))
data, svcErr := h.svc.CitationSummary(c.Request.Context(), days, brandID, keywordID, c.Query("business_date"), aiPlatformID)
if svcErr != nil { if svcErr != nil {
response.Error(c, svcErr) response.Error(c, svcErr)
return return