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:
@@ -432,7 +432,7 @@ const enUS = {
|
||||
backToQuestions: "Back to questions",
|
||||
citationRanking: "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",
|
||||
citationWindow30: "Last 30 days",
|
||||
citationWindowLabel: "Last {days} days",
|
||||
|
||||
@@ -433,7 +433,7 @@ const zhCN = {
|
||||
backToQuestions: "返回问题列表",
|
||||
citationRanking: "Citation Ranking",
|
||||
citationRankingTitle: "引用排行",
|
||||
citationRankingHint: "按主体域名归因 SaaS 发文在模型引用来源中的出现次数与占比。",
|
||||
citationRankingHint: "按发布外链主体归因 SaaS 发文在模型引用来源中的出现次数与占比。",
|
||||
citationWindow7: "近 7 天",
|
||||
citationWindow30: "近 30 天",
|
||||
citationWindowLabel: "近 {days} 天",
|
||||
|
||||
@@ -1078,6 +1078,10 @@ export const monitoringApi = {
|
||||
},
|
||||
citationSummary(params: {
|
||||
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", {
|
||||
params,
|
||||
|
||||
@@ -251,10 +251,19 @@ const citationSummaryQuery = useQuery({
|
||||
queryKey: computed(() => [
|
||||
"tracking",
|
||||
"citation-summary",
|
||||
selectedBrandId.value,
|
||||
selectedKeywordId.value,
|
||||
selectedPlatformId.value,
|
||||
selectedBusinessDate.value,
|
||||
selectedCitationWindowDays.value,
|
||||
]),
|
||||
enabled: computed(() => Boolean(selectedBrandId.value)),
|
||||
queryFn: () =>
|
||||
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,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user