feat(monitoring): attribute SaaS citations by registrable domain
Persist host/registrable_domain/site_key on monitoring article URL aliases, populate them on desktop-publish completion, and use them in the citation pipeline to count how often SaaS-published content appears as a citation source. Expose a /citation-summary endpoint with a 7/30-day window switch and surface the new source-share metrics in the tracking dashboard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -274,9 +275,31 @@ type monitoringLeasedTaskRow struct {
|
||||
}
|
||||
|
||||
type monitoringAliasResolution struct {
|
||||
ArticleID *int64
|
||||
PublishRecordID *int64
|
||||
NormalizedURLKey string
|
||||
ArticleID *int64
|
||||
PublishRecordID *int64
|
||||
NormalizedURLKey string
|
||||
ResolutionStatus string
|
||||
ResolutionConfidence string
|
||||
}
|
||||
|
||||
type monitoringAliasLookupInput struct {
|
||||
NormalizedURL string
|
||||
RegistrableDomain string
|
||||
SiteKey string
|
||||
Host string
|
||||
LastPathSegment *string
|
||||
TitleKey string
|
||||
}
|
||||
|
||||
type monitoringAliasCandidate struct {
|
||||
NormalizedURL string
|
||||
ArticleID *int64
|
||||
PublishRecordID *int64
|
||||
RegistrableDomain string
|
||||
SiteKey string
|
||||
Host string
|
||||
LastPathSegment string
|
||||
TitleKey string
|
||||
}
|
||||
|
||||
type monitoringCitationFact struct {
|
||||
@@ -2400,7 +2423,7 @@ func (s *MonitoringCallbackService) buildCitationFacts(ctx context.Context, tx p
|
||||
resolveContentCitations := monitoringPlatformResolvesContentCitations(platformID)
|
||||
aliasMap := map[string]monitoringAliasResolution{}
|
||||
if resolveContentCitations {
|
||||
normalizedURLs := make([]string, 0, len(inputs))
|
||||
lookupInputs := make([]monitoringAliasLookupInput, 0, len(inputs))
|
||||
seenURLs := make(map[string]struct{}, len(inputs))
|
||||
for _, item := range inputs {
|
||||
key := strings.TrimSpace(monitoringStringValue(item.NormalizedURL))
|
||||
@@ -2414,11 +2437,23 @@ func (s *MonitoringCallbackService) buildCitationFacts(ctx context.Context, tx p
|
||||
continue
|
||||
}
|
||||
seenURLs[key] = struct{}{}
|
||||
normalizedURLs = append(normalizedURLs, key)
|
||||
host, registrableDomain, _, _ := deriveCitationURLParts(item.URL, key)
|
||||
siteKey := strings.TrimSpace(monitoringStringValue(item.SiteKey))
|
||||
if siteKey == "" {
|
||||
siteKey = registrableDomain
|
||||
}
|
||||
lookupInputs = append(lookupInputs, monitoringAliasLookupInput{
|
||||
NormalizedURL: key,
|
||||
RegistrableDomain: strings.ToLower(strings.TrimSpace(registrableDomain)),
|
||||
SiteKey: strings.ToLower(strings.TrimSpace(siteKey)),
|
||||
Host: strings.ToLower(strings.TrimSpace(host)),
|
||||
LastPathSegment: monitoringAliasLastPathSegment(key),
|
||||
TitleKey: normalizeMonitoringAliasTitle(monitoringStringValue(item.Title)),
|
||||
})
|
||||
}
|
||||
|
||||
var err error
|
||||
aliasMap, err = s.loadAliasResolutions(ctx, tx, tenantID, normalizedURLs)
|
||||
aliasMap, err = s.loadAliasResolutions(ctx, tx, tenantID, lookupInputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2443,40 +2478,205 @@ func (s *MonitoringCallbackService) buildCitationFacts(ctx context.Context, tx p
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx pgx.Tx, tenantID int64, normalizedURLs []string) (map[string]monitoringAliasResolution, error) {
|
||||
func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx pgx.Tx, tenantID int64, lookupInputs []monitoringAliasLookupInput) (map[string]monitoringAliasResolution, error) {
|
||||
result := make(map[string]monitoringAliasResolution)
|
||||
if len(normalizedURLs) == 0 {
|
||||
if len(lookupInputs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
normalizedURLs := make([]string, 0, len(lookupInputs))
|
||||
domainKeys := make([]string, 0, len(lookupInputs)*3)
|
||||
seenDomainKeys := map[string]struct{}{}
|
||||
for _, input := range lookupInputs {
|
||||
if input.NormalizedURL != "" {
|
||||
normalizedURLs = append(normalizedURLs, input.NormalizedURL)
|
||||
}
|
||||
for _, key := range []string{input.RegistrableDomain, input.SiteKey, input.Host} {
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenDomainKeys[key]; ok {
|
||||
continue
|
||||
}
|
||||
seenDomainKeys[key] = struct{}{}
|
||||
domainKeys = append(domainKeys, key)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT normalized_url, article_id, publish_record_id
|
||||
SELECT
|
||||
normalized_url,
|
||||
article_id,
|
||||
publish_record_id,
|
||||
registrable_domain,
|
||||
site_key,
|
||||
host,
|
||||
last_path_segment,
|
||||
article_title_snapshot
|
||||
FROM monitoring_article_url_aliases
|
||||
WHERE tenant_id = $1
|
||||
AND normalized_url = ANY($2)
|
||||
`, tenantID, normalizedURLs)
|
||||
AND (
|
||||
normalized_url = ANY($2::text[])
|
||||
OR NULLIF(LOWER(TRIM(registrable_domain)), '') = ANY($3::text[])
|
||||
OR NULLIF(LOWER(TRIM(site_key)), '') = ANY($3::text[])
|
||||
OR NULLIF(LOWER(TRIM(host)), '') = ANY($3::text[])
|
||||
)
|
||||
`, tenantID, normalizedURLs, domainKeys)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50041, "alias_lookup_failed", "failed to resolve article aliases")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
candidates := make([]monitoringAliasCandidate, 0)
|
||||
for rows.Next() {
|
||||
var normalizedURL string
|
||||
var candidate monitoringAliasCandidate
|
||||
var articleID sql.NullInt64
|
||||
var publishRecordID sql.NullInt64
|
||||
if scanErr := rows.Scan(&normalizedURL, &articleID, &publishRecordID); scanErr != nil {
|
||||
var registrableDomain sql.NullString
|
||||
var siteKey sql.NullString
|
||||
var host sql.NullString
|
||||
var lastPathSegment sql.NullString
|
||||
var articleTitle sql.NullString
|
||||
if scanErr := rows.Scan(
|
||||
&candidate.NormalizedURL,
|
||||
&articleID,
|
||||
&publishRecordID,
|
||||
®istrableDomain,
|
||||
&siteKey,
|
||||
&host,
|
||||
&lastPathSegment,
|
||||
&articleTitle,
|
||||
); scanErr != nil {
|
||||
return nil, response.ErrInternal(50041, "alias_scan_failed", "failed to parse article alias resolution")
|
||||
}
|
||||
result[normalizedURL] = monitoringAliasResolution{
|
||||
ArticleID: nullableInt64Value(articleID),
|
||||
PublishRecordID: nullableInt64Value(publishRecordID),
|
||||
NormalizedURLKey: normalizedURL,
|
||||
candidate.ArticleID = nullableInt64Value(articleID)
|
||||
candidate.PublishRecordID = nullableInt64Value(publishRecordID)
|
||||
candidate.RegistrableDomain = strings.ToLower(strings.TrimSpace(nullableStringText(registrableDomain)))
|
||||
candidate.SiteKey = strings.ToLower(strings.TrimSpace(nullableStringText(siteKey)))
|
||||
candidate.Host = strings.ToLower(strings.TrimSpace(nullableStringText(host)))
|
||||
candidate.LastPathSegment = strings.TrimSpace(nullableStringText(lastPathSegment))
|
||||
candidate.TitleKey = normalizeMonitoringAliasTitle(nullableStringText(articleTitle))
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, response.ErrInternal(50041, "alias_scan_failed", "failed to iterate article aliases")
|
||||
}
|
||||
|
||||
for _, input := range lookupInputs {
|
||||
bestScore := -1
|
||||
bestAmbiguous := false
|
||||
var best monitoringAliasCandidate
|
||||
domainMatchedArticleIDs := map[int64]struct{}{}
|
||||
for _, candidate := range candidates {
|
||||
if monitoringAliasDomainMatches(input, candidate) && candidate.ArticleID != nil {
|
||||
domainMatchedArticleIDs[*candidate.ArticleID] = struct{}{}
|
||||
}
|
||||
score := scoreMonitoringAliasCandidate(input, candidate)
|
||||
if score < 0 {
|
||||
continue
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestAmbiguous = false
|
||||
best = candidate
|
||||
continue
|
||||
}
|
||||
if score == bestScore && !sameInt64Pointer(candidate.ArticleID, best.ArticleID) {
|
||||
bestAmbiguous = true
|
||||
}
|
||||
}
|
||||
if bestScore == 40 && len(domainMatchedArticleIDs) == 1 {
|
||||
bestScore = 60
|
||||
}
|
||||
|
||||
if bestScore < 60 || bestAmbiguous {
|
||||
continue
|
||||
}
|
||||
confidence := "medium"
|
||||
status := "domain_matched"
|
||||
if bestScore >= 100 {
|
||||
confidence = "high"
|
||||
status = "resolved"
|
||||
} else if bestScore < 80 {
|
||||
confidence = "low"
|
||||
}
|
||||
result[input.NormalizedURL] = monitoringAliasResolution{
|
||||
ArticleID: cloneInt64(best.ArticleID),
|
||||
PublishRecordID: cloneInt64(best.PublishRecordID),
|
||||
NormalizedURLKey: best.NormalizedURL,
|
||||
ResolutionStatus: status,
|
||||
ResolutionConfidence: confidence,
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scoreMonitoringAliasCandidate(input monitoringAliasLookupInput, candidate monitoringAliasCandidate) int {
|
||||
if input.NormalizedURL != "" && candidate.NormalizedURL == input.NormalizedURL {
|
||||
return 100
|
||||
}
|
||||
|
||||
if !monitoringAliasDomainMatches(input, candidate) {
|
||||
return -1
|
||||
}
|
||||
|
||||
score := 40
|
||||
if input.LastPathSegment != nil && strings.TrimSpace(*input.LastPathSegment) != "" && candidate.LastPathSegment == strings.TrimSpace(*input.LastPathSegment) {
|
||||
score += 30
|
||||
}
|
||||
if input.TitleKey != "" && candidate.TitleKey != "" {
|
||||
switch {
|
||||
case input.TitleKey == candidate.TitleKey:
|
||||
score += 30
|
||||
case strings.Contains(input.TitleKey, candidate.TitleKey) || strings.Contains(candidate.TitleKey, input.TitleKey):
|
||||
score += 15
|
||||
}
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func monitoringAliasDomainMatches(input monitoringAliasLookupInput, candidate monitoringAliasCandidate) bool {
|
||||
inputKeys := []string{input.RegistrableDomain, input.SiteKey, input.Host}
|
||||
candidateKeys := []string{candidate.RegistrableDomain, candidate.SiteKey, candidate.Host}
|
||||
for _, inputKey := range inputKeys {
|
||||
inputKey = strings.ToLower(strings.TrimSpace(inputKey))
|
||||
if inputKey == "" {
|
||||
continue
|
||||
}
|
||||
for _, candidateKey := range candidateKeys {
|
||||
if inputKey == strings.ToLower(strings.TrimSpace(candidateKey)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeMonitoringAliasTitle(value string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
||||
return unicode.ToLower(r)
|
||||
}
|
||||
return -1
|
||||
}, value)
|
||||
}
|
||||
|
||||
func nullableStringText(value sql.NullString) string {
|
||||
if !value.Valid {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(value.String)
|
||||
}
|
||||
|
||||
func sameInt64Pointer(left, right *int64) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == right
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
func (s *MonitoringCallbackService) insertCitationFact(ctx context.Context, tx pgx.Tx, task *monitoringCollectTask, runID int64, fact monitoringCitationFact) error {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO monitoring_citation_facts (
|
||||
@@ -2809,10 +3009,13 @@ func buildMonitoringCitationFact(input MonitoringSourceItem, aliasMap map[string
|
||||
|
||||
var articleID *int64
|
||||
var publishRecordID *int64
|
||||
var aliasResolution *monitoringAliasResolution
|
||||
if resolveContentCitations {
|
||||
articleID = cloneInt64(input.ArticleID)
|
||||
publishRecordID = cloneInt64(input.PublishRecordID)
|
||||
if alias, ok := aliasMap[normalizedURL]; ok {
|
||||
aliasCopy := alias
|
||||
aliasResolution = &aliasCopy
|
||||
if articleID == nil {
|
||||
articleID = cloneInt64(alias.ArticleID)
|
||||
}
|
||||
@@ -2824,11 +3027,19 @@ func buildMonitoringCitationFact(input MonitoringSourceItem, aliasMap map[string
|
||||
|
||||
resolutionStatus := strings.TrimSpace(monitoringStringValue(input.ResolutionStatus))
|
||||
if resolutionStatus == "" {
|
||||
resolutionStatus = "resolved"
|
||||
if aliasResolution != nil && strings.TrimSpace(aliasResolution.ResolutionStatus) != "" {
|
||||
resolutionStatus = strings.TrimSpace(aliasResolution.ResolutionStatus)
|
||||
} else {
|
||||
resolutionStatus = "resolved"
|
||||
}
|
||||
}
|
||||
resolutionConfidence := strings.TrimSpace(monitoringStringValue(input.ResolutionConfidence))
|
||||
if resolutionConfidence == "" {
|
||||
resolutionConfidence = "high"
|
||||
if aliasResolution != nil && strings.TrimSpace(aliasResolution.ResolutionConfidence) != "" {
|
||||
resolutionConfidence = strings.TrimSpace(aliasResolution.ResolutionConfidence)
|
||||
} else {
|
||||
resolutionConfidence = "high"
|
||||
}
|
||||
}
|
||||
|
||||
return monitoringCitationFact{
|
||||
@@ -3113,7 +3324,7 @@ func normalizeCitationURL(raw string) string {
|
||||
parsed.Host = strings.ToLower(parsed.Host)
|
||||
parsed.Fragment = ""
|
||||
parsed.RawFragment = ""
|
||||
parsed.RawQuery = ""
|
||||
parsed.RawQuery = normalizeCitationQuery(parsed)
|
||||
|
||||
if parsed.Path != "/" {
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
@@ -3122,6 +3333,30 @@ func normalizeCitationURL(raw string) string {
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func normalizeCitationQuery(parsed *url.URL) string {
|
||||
if parsed == nil {
|
||||
return ""
|
||||
}
|
||||
host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
|
||||
allowedParams := []string{}
|
||||
switch {
|
||||
case host == "baijiahao.baidu.com":
|
||||
allowedParams = []string{"id"}
|
||||
}
|
||||
if len(allowedParams) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
query := parsed.Query()
|
||||
normalized := url.Values{}
|
||||
for _, key := range allowedParams {
|
||||
if value := strings.TrimSpace(query.Get(key)); value != "" {
|
||||
normalized.Set(key, value)
|
||||
}
|
||||
}
|
||||
return normalized.Encode()
|
||||
}
|
||||
|
||||
func deriveCitationURLParts(rawURL, normalizedURL string) (string, string, *string, *string) {
|
||||
candidate := strings.TrimSpace(normalizedURL)
|
||||
if candidate == "" {
|
||||
|
||||
Reference in New Issue
Block a user