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 {