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
@@ -284,6 +284,7 @@ type monitoringAliasResolution struct {
type monitoringAliasLookupInput struct {
NormalizedURL string
MatchKeys []string
RegistrableDomain string
SiteKey string
Host string
@@ -293,6 +294,7 @@ type monitoringAliasLookupInput struct {
type monitoringAliasCandidate struct {
NormalizedURL string
MatchKeys []string
ArticleID *int64
PublishRecordID *int64
RegistrableDomain string
@@ -2444,6 +2446,7 @@ func (s *MonitoringCallbackService) buildCitationFacts(ctx context.Context, tx p
}
lookupInputs = append(lookupInputs, monitoringAliasLookupInput{
NormalizedURL: key,
MatchKeys: monitoringCitationCandidateKeys(item.URL, key),
RegistrableDomain: strings.ToLower(strings.TrimSpace(registrableDomain)),
SiteKey: strings.ToLower(strings.TrimSpace(siteKey)),
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.LastPathSegment = strings.TrimSpace(nullableStringText(lastPathSegment))
candidate.TitleKey = normalizeMonitoringAliasTitle(nullableStringText(articleTitle))
candidate.MatchKeys = monitoringCitationCandidateKeys(candidate.NormalizedURL, candidate.NormalizedURL)
candidates = append(candidates, candidate)
}
if err := rows.Err(); err != nil {
@@ -2567,11 +2571,7 @@ func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx
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
@@ -2586,27 +2586,16 @@ func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx
bestAmbiguous = true
}
}
if bestScore == 40 && len(domainMatchedArticleIDs) == 1 {
bestScore = 60
}
if bestScore < 60 || bestAmbiguous {
if bestScore < 100 || 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,
ResolutionStatus: "resolved",
ResolutionConfidence: "high",
}
}
@@ -2618,23 +2607,19 @@ func scoreMonitoringAliasCandidate(input monitoringAliasLookupInput, candidate m
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
for _, inputKey := range input.MatchKeys {
inputKey = strings.TrimSpace(inputKey)
if inputKey == "" {
continue
}
for _, candidateKey := range candidate.MatchKeys {
if inputKey == strings.TrimSpace(candidateKey) && candidateKey != "" {
return 100
}
}
}
return score
return -1
}
func monitoringAliasDomainMatches(input monitoringAliasLookupInput, candidate monitoringAliasCandidate) bool {
@@ -425,23 +425,33 @@ func TestBuildMonitoringCitationFactCanDisableContentResolution(t *testing.T) {
}
}
func TestScoreMonitoringAliasCandidateUsesDomainWithTitleEvidence(t *testing.T) {
lastPath := "article-42"
func TestScoreMonitoringAliasCandidateUsesCanonicalArticleKey(t *testing.T) {
input := monitoringAliasLookupInput{
NormalizedURL: "https://m.example.com/article-42",
RegistrableDomain: "example.com",
LastPathSegment: &lastPath,
TitleKey: normalizeMonitoringAliasTitle("北京口腔医院全面测评"),
NormalizedURL: "https://m.163.com/dy/article/KRPRE8DJ0556MCR0.html",
MatchKeys: monitoringCitationCandidateKeys("https://m.163.com/dy/article/KRPRE8DJ0556MCR0.html", ""),
}
candidate := monitoringAliasCandidate{
NormalizedURL: "https://www.example.com/posts/article-42",
RegistrableDomain: "example.com",
LastPathSegment: "article-42",
TitleKey: normalizeMonitoringAliasTitle("北京口腔医院全面测评"),
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 < 90 {
t.Fatalf("scoreMonitoringAliasCandidate() = %d, want strong domain/title match", score)
if score := scoreMonitoringAliasCandidate(input, candidate); score != 100 {
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(
ctx context.Context,
days int,
brandID int64,
keywordID *int64,
businessDate string,
aiPlatformID *string,
) (*MonitoringCitationSummaryResponse, error) {
actor := auth.MustActor(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)
if err != nil {
return nil, err
}
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)
if err != nil {
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 {
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 {
return nil, err
}
@@ -2481,98 +2507,10 @@ func (s *MonitoringService) loadCitationRanking(
}
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
rows, err := s.monitoringPool.Query(ctx, `
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))
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load citation ranking")
return nil, err
}
defer rows.Close()
type citationRankingAggregate struct {
CitedAnswerCount int64
@@ -2583,14 +2521,29 @@ func (s *MonitoringService) loadCitationRanking(
}
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() {
var platformID string
var sampleCount int64
var citedAnswerCount int64
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 {
if scanErr := rows.Scan(&platformID, &sampleCount); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking")
}
platformID = normalizeMonitoringPlatformID(platformID)
@@ -2598,16 +2551,80 @@ func (s *MonitoringService) loadCitationRanking(
continue
}
item := aggregates[platformID]
item.CitedAnswerCount += citedAnswerCount
item.CitedArticleCount += citedArticleCount
item.CitationSourceCount += citationSourceCount
item.SaaSSourceCount += saasSourceCount
item.SampleCount += sampleCount
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))
for platformID, aggregate := range aggregates {
if aggregate.SaaSSourceCount == 0 {
continue
}
items = append(items, MonitoringCitationRanking{
AIPlatformID: platformID,
PlatformName: platformDisplayName(platformID),
@@ -2648,6 +2665,11 @@ func (s *MonitoringService) loadCitedArticles(
}
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
if err != nil {
return nil, err
}
var totalSampleCount int64
if err := s.monitoringPool.QueryRow(ctx, `
SELECT COUNT(*)
@@ -2664,78 +2686,78 @@ 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,
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
cf.cited_url,
cf.normalized_url
FROM monitoring_citation_facts cf
JOIN question_monitor_runs r
ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id
WHERE cf.tenant_id = $1
AND ($2::bigint = 0 OR cf.brand_id = $2)
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))
`, 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 cited articles")
}
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() {
var articleID int64
var title string
var publishPlatform string
var citationCount int64
var totalArticleCitationCount int64
if scanErr := rows.Scan(&articleID, &title, &publishPlatform, &citationCount, &totalArticleCitationCount); scanErr != nil {
var citedURL string
var normalizedURL string
if scanErr := rows.Scan(&citedURL, &normalizedURL); scanErr != nil {
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{
ArticleID: articleID,
ArticleTitle: title,
PublishPlatform: publishPlatform,
CitationCount: citationCount,
CitationRate: divideAsPointer(citationCount, totalSampleCount),
SourceShare: divideAsPointer(citationCount, totalArticleCitationCount),
ArticleID: item.ArticleID,
ArticleTitle: item.ArticleTitle,
PublishPlatform: item.PublishPlatform,
CitationCount: item.CitationCount,
CitationRate: divideAsPointer(item.CitationCount, totalSampleCount),
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
}
@@ -2923,6 +2945,11 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
return result, nil
}
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
if err != nil {
return nil, err
}
rows, err := s.monitoringPool.Query(ctx, `
WITH filtered_citations AS (
SELECT
@@ -2930,10 +2957,10 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
cf.run_id,
cf.cited_url,
cf.cited_title,
cf.normalized_url,
cf.host,
cf.registrable_domain,
cf.site_key,
cf.article_id,
cf.resolution_status,
cf.resolution_confidence
FROM monitoring_citation_facts cf
@@ -2979,10 +3006,9 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
fc.run_id,
fc.cited_url,
fc.cited_title,
fc.normalized_url,
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,
fc.article_id,
alias.article_title_snapshot AS article_title,
fc.resolution_status,
fc.resolution_confidence
FROM filtered_citations fc
@@ -2990,8 +3016,6 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
ON dm.host = fc.host
AND dm.registrable_domain = fc.registrable_domain
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
`, tenantID, runIDs)
if err != nil {
@@ -3003,21 +3027,26 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
var runID int64
var citedURL string
var citedTitle sql.NullString
var normalizedURL string
var siteName string
var siteKey string
var articleID sql.NullInt64
var articleTitle sql.NullString
var resolutionStatus 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")
}
siteName = repairMonitoringMojibake(siteName)
if citedTitle.Valid {
citedTitle.String = repairMonitoringMojibake(citedTitle.String)
}
if articleTitle.Valid {
articleTitle.String = repairMonitoringMojibake(articleTitle.String)
var articleID *int64
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{
CitedURL: citedURL,
@@ -3025,12 +3054,15 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
SiteName: siteName,
SiteKey: siteKey,
FaviconURL: faviconURL(siteKey),
ArticleID: nullableInt64Value(articleID),
ArticleTitle: nullableStringValue(articleTitle),
ArticleID: articleID,
ArticleTitle: articleTitle,
ResolutionStatus: resolutionStatus,
ResolutionConfidence: resolutionConfidence,
})
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate citations")
}
return result, nil
}
@@ -3040,15 +3072,21 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
return []MonitoringQuestionCitationStats{}, nil
}
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
if err != nil {
return nil, err
}
rows, err := s.monitoringPool.Query(ctx, `
WITH filtered_facts AS (
SELECT
r.ai_platform_id,
cf.id,
cf.cited_url,
cf.normalized_url,
cf.host,
cf.registrable_domain,
cf.site_key,
cf.article_id
cf.site_key
FROM question_monitor_runs r
JOIN monitoring_citation_facts cf
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
LIMIT 1
) 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
g.ai_platform_id,
g.site_name,
g.site_key,
g.site_domain,
g.citation_count,
CASE
WHEN t.total_citation_count > 0
THEN g.citation_count::double precision / t.total_citation_count::double precision
ELSE NULL
END AS citation_rate,
g.content_citation_count
FROM grouped g
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
ff.ai_platform_id,
ff.cited_url,
ff.normalized_url,
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
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
ORDER BY ff.ai_platform_id ASC, ff.id ASC
`, tenantID, runIDs)
if err != nil {
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)
for rows.Next() {
var platformID string
var citedURL string
var normalizedURL string
var siteName string
var siteKey string
var siteDomain string
var citationCount int64
var contentCitationCount int64
if scanErr := rows.Scan(&platformID, &siteName, &siteKey, &siteDomain, &citationCount, new(sql.NullFloat64), &contentCitationCount); scanErr != nil {
if scanErr := rows.Scan(&platformID, &citedURL, &normalizedURL, &siteName, &siteKey, &siteDomain); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse question citation analysis")
}
platformID = normalizeMonitoringPlatformID(platformID)
@@ -3174,10 +3183,17 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
SiteDomain: siteDomain,
}
}
item.CitationCount += citationCount
item.ContentCitationCount += contentCitationCount
item.CitationCount++
if platformID != "qwen" {
if _, ok := aliasIndex.Match(citedURL, normalizedURL); ok {
item.ContentCitationCount++
}
}
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))
@@ -3206,40 +3222,23 @@ func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, te
return []MonitoringQuestionContentCitation{}, nil
}
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
if err != nil {
return nil, err
}
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
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,
COUNT(*) AS citation_count,
MAX(pt.total_citation_count) AS total_citation_count
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
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
AND r.status = 'succeeded'
AND cf.article_id IS NOT NULL
AND r.ai_platform_id <> 'qwen'
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)
if err != nil {
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)
totals := make(map[string]int64)
for rows.Next() {
var articleID int64
var articleTitle string
var publishPlatform string
var platformID string
var citationCount int64
var totalCitationCount int64
if scanErr := rows.Scan(&articleID, &articleTitle, &publishPlatform, &platformID, &citationCount, &totalCitationCount); scanErr != nil {
var citedURL string
var normalizedURL string
if scanErr := rows.Scan(&platformID, &citedURL, &normalizedURL); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse content citations")
}
platformID = normalizeMonitoringPlatformID(platformID)
if platformID == "" {
continue
}
totals[platformID]++
alias, ok := aliasIndex.Match(citedURL, normalizedURL)
if !ok {
continue
}
key := contentCitationKey{
PlatformID: platformID,
ArticleID: articleID,
ArticleID: alias.ArticleID,
}
item := aggregates[key]
if item.ArticleID == 0 {
item = MonitoringQuestionContentCitation{
ArticleID: articleID,
ArticleTitle: articleTitle,
PublishPlatform: publishPlatform,
ArticleID: alias.ArticleID,
ArticleTitle: alias.ArticleTitle,
PublishPlatform: alias.PublishPlatform,
AIPlatformID: platformID,
PlatformName: platformDisplayName(platformID),
}
}
item.CitationCount += citationCount
item.CitationCount++
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))
@@ -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) {
if got := normalizePublishStatus("partial_success"); got != "partial_success" {
t.Fatalf("normalizePublishStatus(partial_success) = %q, want partial_success", got)
@@ -66,7 +66,21 @@ func (h *MonitoringHandler) CitationSummary(c *gin.Context) {
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 {
response.Error(c, svcErr)
return