fix monitoring manual mark attribution
Frontend CI / Frontend (push) Successful in 2m56s
Backend CI / Backend (push) Successful in 14m47s

This commit is contained in:
2026-06-25 09:31:18 +08:00
parent bdfe79c921
commit cbbec8da85
10 changed files with 436 additions and 66 deletions
@@ -3213,11 +3213,11 @@ func (s *MonitoringCallbackService) loadExistingRunSummary(ctx context.Context,
if err := s.monitoringPool.QueryRow(ctx, `
SELECT
COUNT(*)::int AS citation_source_count,
COUNT(*) FILTER (WHERE content_source_type IS NOT NULL AND content_source_id IS NOT NULL AND $3::text <> 'qwen')::int AS content_citation_count
COUNT(*) FILTER (WHERE content_source_type IS NOT NULL AND content_source_id IS NOT NULL)::int AS content_citation_count
FROM monitoring_citation_facts
WHERE tenant_id = $1
AND run_id = $2
`, task.TenantID, runID.Int64, normalizeMonitoringPlatformID(task.AIPlatformID)).Scan(&citationSourceCount, &contentCitationCount); err != nil {
WHERE tenant_id = $1
AND run_id = $2
`, task.TenantID, runID.Int64).Scan(&citationSourceCount, &contentCitationCount); err != nil {
return nil, 0, 0, response.ErrInternal(50041, "citation_lookup_failed", "failed to inspect existing citation facts")
}
@@ -3340,12 +3340,7 @@ func monitoringPlatformUsesDedicatedCitationPanel(platformID string) bool {
}
func monitoringPlatformResolvesContentCitations(platformID string) bool {
switch normalizeMonitoringPlatformID(platformID) {
case "qwen":
return false
default:
return true
}
return normalizeMonitoringPlatformID(platformID) != ""
}
func newMonitoringTaskResultEnvelope(task *monitoringCollectTask, installationID string, receivedAt time.Time, req MonitoringTaskResultRequest) monitoringTaskResultEnvelope {
@@ -535,7 +535,17 @@ func TestBuildMonitoringRawPayloadQwenIncludesSearchResultsInCitationInputs(t *t
}
}
func TestBuildMonitoringCitationFactCanDisableContentResolution(t *testing.T) {
func TestMonitoringPlatformResolvesContentCitationsForAllKnownModels(t *testing.T) {
for _, platformID := range []string{"doubao", "qwen", "kimi", "deepseek", "wenxin", "yuanbao"} {
t.Run(platformID, func(t *testing.T) {
if !monitoringPlatformResolvesContentCitations(platformID) {
t.Fatalf("monitoringPlatformResolvesContentCitations(%q) = false, want true", platformID)
}
})
}
}
func TestBuildMonitoringCitationFactResolvesContentForQwen(t *testing.T) {
aliasArticleID := int64(42)
aliasPublishRecordID := int64(84)
normalizedURL := "https://example.com/article"
@@ -561,15 +571,15 @@ func TestBuildMonitoringCitationFactCanDisableContentResolution(t *testing.T) {
t.Fatalf("resolved PublishRecordID = %v, want %d", resolvedFact.PublishRecordID, aliasPublishRecordID)
}
sourceOnlyFact, ok := buildMonitoringCitationFact(input, aliasMap, false)
sourceOnlyFact, ok := buildMonitoringCitationFact(input, aliasMap, monitoringPlatformResolvesContentCitations("qwen"))
if !ok {
t.Fatal("buildMonitoringCitationFact() without content resolution returned !ok")
t.Fatal("buildMonitoringCitationFact() for qwen returned !ok")
}
if sourceOnlyFact.ArticleID != nil {
t.Fatalf("source-only ArticleID = %v, want nil", sourceOnlyFact.ArticleID)
if sourceOnlyFact.ArticleID == nil || *sourceOnlyFact.ArticleID != aliasArticleID {
t.Fatalf("qwen ArticleID = %v, want %d", sourceOnlyFact.ArticleID, aliasArticleID)
}
if sourceOnlyFact.PublishRecordID != nil {
t.Fatalf("source-only PublishRecordID = %v, want nil", sourceOnlyFact.PublishRecordID)
if sourceOnlyFact.PublishRecordID == nil || *sourceOnlyFact.PublishRecordID != aliasPublishRecordID {
t.Fatalf("qwen PublishRecordID = %v, want %d", sourceOnlyFact.PublishRecordID, aliasPublishRecordID)
}
}
@@ -5,11 +5,13 @@ import (
"database/sql"
"fmt"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"go.uber.org/zap"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/response"
@@ -61,6 +63,11 @@ type monitoringMarkedArticleURLMeta struct {
LastPathSegment *string
}
type monitoringProjectionRefreshScope struct {
BrandID int64
BusinessDate time.Time
}
func (s *MonitoringService) ListMarkedArticles(ctx context.Context, params MonitoringMarkedArticleListParams) (*MonitoringMarkedArticleListResponse, error) {
actor := auth.MustActor(ctx)
pageReq := normalizeMonitoringPageRequest(MonitoringPageRequest{
@@ -208,9 +215,11 @@ func (s *MonitoringService) CreateMarkedArticle(ctx context.Context, req Monitor
return nil, response.ErrInternal(50041, "marked_article_create_failed", "failed to save marked article")
}
if err := s.syncMarkedArticleCitationFacts(ctx, actor.TenantID, id, meta.NormalizedURL); err != nil {
scopes, err := s.syncMarkedArticleCitationFacts(ctx, actor.TenantID, id, meta)
if err != nil {
return nil, err
}
s.refreshMarkedArticleProjectionScopes(ctx, actor.TenantID, scopes)
return s.GetMarkedArticle(ctx, id)
}
@@ -260,14 +269,20 @@ func (s *MonitoringService) UpdateMarkedArticle(ctx context.Context, id int64, r
if tag.RowsAffected() == 0 {
return nil, response.ErrNotFound(40441, "marked_article_not_found", "marked article not found")
}
scopes := make([]monitoringProjectionRefreshScope, 0)
if oldNormalizedURL != "" && oldNormalizedURL != meta.NormalizedURL {
if err := s.clearMarkedArticleCitationFacts(ctx, actor.TenantID, id, oldNormalizedURL); err != nil {
clearedScopes, err := s.clearMarkedArticleCitationFacts(ctx, actor.TenantID, id, "")
if err != nil {
return nil, err
}
scopes = append(scopes, clearedScopes...)
}
if err := s.syncMarkedArticleCitationFacts(ctx, actor.TenantID, id, meta.NormalizedURL); err != nil {
syncedScopes, err := s.syncMarkedArticleCitationFacts(ctx, actor.TenantID, id, meta)
if err != nil {
return nil, err
}
scopes = append(scopes, syncedScopes...)
s.refreshMarkedArticleProjectionScopes(ctx, actor.TenantID, scopes)
return s.GetMarkedArticle(ctx, id)
}
@@ -297,9 +312,11 @@ func (s *MonitoringService) DeleteMarkedArticle(ctx context.Context, id int64) e
if tag.RowsAffected() == 0 {
return response.ErrNotFound(40441, "marked_article_not_found", "marked article not found")
}
if err := s.clearMarkedArticleCitationFacts(ctx, actor.TenantID, id, normalizedURL); err != nil {
scopes, err := s.clearMarkedArticleCitationFacts(ctx, actor.TenantID, id, "")
if err != nil {
return err
}
s.refreshMarkedArticleProjectionScopes(ctx, actor.TenantID, scopes)
return nil
}
@@ -431,27 +448,95 @@ func normalizeMarkedArticleDomain(value string) string {
return trimmed
}
func (s *MonitoringService) syncMarkedArticleCitationFacts(ctx context.Context, tenantID, markedArticleID int64, normalizedURL string) error {
if tenantID <= 0 || markedArticleID <= 0 || strings.TrimSpace(normalizedURL) == "" {
return nil
func (s *MonitoringService) syncMarkedArticleCitationFacts(ctx context.Context, tenantID, markedArticleID int64, meta monitoringMarkedArticleURLMeta) ([]monitoringProjectionRefreshScope, error) {
if tenantID <= 0 || markedArticleID <= 0 || strings.TrimSpace(meta.NormalizedURL) == "" {
return nil, nil
}
matches, scopes, err := s.findMarkedArticleCitationFactMatches(ctx, tenantID, markedArticleID, meta)
if err != nil {
return nil, err
}
if len(matches) == 0 {
return scopes, nil
}
if _, err := s.monitoringPool.Exec(ctx, `
UPDATE monitoring_citation_facts
SET content_source_type = 'manual_mark',
content_source_id = $3
WHERE tenant_id = $1
AND normalized_url = $2
AND id = ANY($2::bigint[])
AND content_source_type IS NULL
AND content_source_id IS NULL
`, tenantID, strings.TrimSpace(normalizedURL), markedArticleID); err != nil {
return response.ErrInternal(50041, "marked_article_citation_sync_failed", "failed to sync marked article citations")
`, tenantID, matches, markedArticleID); err != nil {
return nil, response.ErrInternal(50041, "marked_article_citation_sync_failed", "failed to sync marked article citations")
}
return nil
return scopes, nil
}
func (s *MonitoringService) clearMarkedArticleCitationFacts(ctx context.Context, tenantID, markedArticleID int64, normalizedURL string) error {
func (s *MonitoringService) findMarkedArticleCitationFactMatches(ctx context.Context, tenantID, markedArticleID int64, meta monitoringMarkedArticleURLMeta) ([]int64, []monitoringProjectionRefreshScope, error) {
targetKeys := monitoringCitationCandidateKeySet(meta.OriginalURL, meta.NormalizedURL)
if len(targetKeys) == 0 {
return nil, nil, nil
}
rows, err := s.monitoringPool.Query(ctx, `
SELECT
id,
cited_url,
normalized_url,
brand_id,
business_date
FROM monitoring_citation_facts
WHERE tenant_id = $1
AND business_date >= (CURRENT_DATE - INTERVAL '6 months')::date
AND (
(content_source_type IS NULL AND content_source_id IS NULL)
OR (content_source_type = 'manual_mark' AND content_source_id = $2)
)
AND (
normalized_url = $3
OR LOWER(registrable_domain) = ANY($4::text[])
OR LOWER(site_key) = ANY($4::text[])
OR LOWER(host) = ANY($4::text[])
)
`, tenantID, markedArticleID, strings.TrimSpace(meta.NormalizedURL), markedArticleDomainKeys(meta))
if err != nil {
return nil, nil, response.ErrInternal(50041, "marked_article_citation_lookup_failed", "failed to inspect marked article citations")
}
defer rows.Close()
matchedIDs := make([]int64, 0)
scopeSet := make(map[string]monitoringProjectionRefreshScope)
for rows.Next() {
var factID int64
var citedURL string
var normalizedURL string
var brandID int64
var businessDate time.Time
if scanErr := rows.Scan(&factID, &citedURL, &normalizedURL, &brandID, &businessDate); scanErr != nil {
return nil, nil, response.ErrInternal(50041, "marked_article_citation_scan_failed", "failed to parse marked article citations")
}
if !monitoringCitationMatchesAnyKey(citedURL, normalizedURL, targetKeys) {
continue
}
matchedIDs = append(matchedIDs, factID)
key := monitoringProjectionScopeKey(brandID, businessDate)
scopeSet[key] = monitoringProjectionRefreshScope{
BrandID: brandID,
BusinessDate: monitoringCanonicalBusinessDay(businessDate),
}
}
if err := rows.Err(); err != nil {
return nil, nil, response.ErrInternal(50041, "marked_article_citation_scan_failed", "failed to iterate marked article citations")
}
return matchedIDs, sortedMonitoringProjectionRefreshScopes(scopeSet), nil
}
func (s *MonitoringService) clearMarkedArticleCitationFacts(ctx context.Context, tenantID, markedArticleID int64, normalizedURL string) ([]monitoringProjectionRefreshScope, error) {
if tenantID <= 0 || markedArticleID <= 0 {
return nil
return nil, nil
}
args := []interface{}{tenantID, markedArticleID}
whereURL := ""
@@ -459,17 +544,147 @@ func (s *MonitoringService) clearMarkedArticleCitationFacts(ctx context.Context,
args = append(args, trimmed)
whereURL = " AND normalized_url = $3"
}
if _, err := s.monitoringPool.Exec(ctx, `
rows, err := s.monitoringPool.Query(ctx, `
UPDATE monitoring_citation_facts
SET content_source_type = NULL,
content_source_id = NULL
WHERE tenant_id = $1
AND content_source_type = 'manual_mark'
AND content_source_id = $2
`+whereURL, args...); err != nil {
return response.ErrInternal(50041, "marked_article_citation_clear_failed", "failed to clear marked article citations")
`+whereURL+`
RETURNING brand_id, business_date
`, args...)
if err != nil {
return nil, response.ErrInternal(50041, "marked_article_citation_clear_failed", "failed to clear marked article citations")
}
return nil
defer rows.Close()
scopeSet := make(map[string]monitoringProjectionRefreshScope)
for rows.Next() {
var brandID int64
var businessDate time.Time
if scanErr := rows.Scan(&brandID, &businessDate); scanErr != nil {
return nil, response.ErrInternal(50041, "marked_article_citation_clear_scan_failed", "failed to parse cleared marked article citations")
}
key := monitoringProjectionScopeKey(brandID, businessDate)
scopeSet[key] = monitoringProjectionRefreshScope{
BrandID: brandID,
BusinessDate: monitoringCanonicalBusinessDay(businessDate),
}
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50041, "marked_article_citation_clear_scan_failed", "failed to iterate cleared marked article citations")
}
return sortedMonitoringProjectionRefreshScopes(scopeSet), nil
}
func (s *MonitoringService) refreshMarkedArticleProjectionScopes(ctx context.Context, tenantID int64, scopes []monitoringProjectionRefreshScope) {
if s == nil || s.monitoringPool == nil || tenantID <= 0 || len(scopes) == 0 {
return
}
grouped := make(map[string][]int64)
for _, scope := range scopes {
if scope.BrandID <= 0 || scope.BusinessDate.IsZero() {
continue
}
dateText := monitoringBusinessDateText(scope.BusinessDate)
grouped[dateText] = append(grouped[dateText], scope.BrandID)
}
if len(grouped) == 0 {
return
}
rebuilder := &MonitoringCallbackService{
monitoringPool: s.monitoringPool,
logger: s.logger,
}
for dateText, brandIDs := range grouped {
businessDate, err := parseMonitoringBusinessDateInLocation(dateText, monitoringBusinessLocation())
if err != nil {
continue
}
rebuildCtx, cancel := context.WithTimeout(ctx, monitoringProjectionFallbackTimeout)
err = rebuilder.rebuildBrandDailyProjectionBatch(rebuildCtx, tenantID, businessDate, brandIDs)
cancel()
if err != nil && s.logger != nil {
s.logger.Warn("marked article projection refresh failed",
zap.Int64("tenant_id", tenantID),
zap.String("business_date", dateText),
zap.Int("brand_count", len(uniqueSortedMonitoringProjectionBrandIDs(brandIDs))),
zap.Error(err),
)
}
}
}
func monitoringCitationCandidateKeySet(rawURL, normalizedURL string) map[string]struct{} {
keys := monitoringCitationCandidateKeys(rawURL, normalizedURL)
result := make(map[string]struct{}, len(keys))
for _, key := range keys {
key = strings.TrimSpace(key)
if key != "" {
result[key] = struct{}{}
}
}
return result
}
func monitoringCitationMatchesAnyKey(rawURL, normalizedURL string, targetKeys map[string]struct{}) bool {
if len(targetKeys) == 0 {
return false
}
for _, key := range monitoringCitationCandidateKeys(rawURL, normalizedURL) {
if _, ok := targetKeys[key]; ok {
return true
}
}
return false
}
func markedArticleDomainKeys(meta monitoringMarkedArticleURLMeta) []string {
values := []string{meta.RegistrableDomain, meta.SiteKey, meta.Host}
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
key := strings.ToLower(strings.TrimSpace(value))
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result = append(result, key)
}
return result
}
func monitoringProjectionScopeKey(brandID int64, businessDate time.Time) string {
return strconv.FormatInt(brandID, 10) + ":" + monitoringBusinessDateText(businessDate)
}
func sortedMonitoringProjectionRefreshScopes(scopeSet map[string]monitoringProjectionRefreshScope) []monitoringProjectionRefreshScope {
if len(scopeSet) == 0 {
return nil
}
items := make([]monitoringProjectionRefreshScope, 0, len(scopeSet))
for _, item := range scopeSet {
if item.BrandID <= 0 || item.BusinessDate.IsZero() {
continue
}
items = append(items, item)
}
sort.Slice(items, func(left, right int) bool {
leftDate := monitoringBusinessDateText(items[left].BusinessDate)
rightDate := monitoringBusinessDateText(items[right].BusinessDate)
if leftDate != rightDate {
return leftDate < rightDate
}
return items[left].BrandID < items[right].BrandID
})
return items
}
func nullableTimePtr(value *time.Time) interface{} {
@@ -216,7 +216,6 @@ func (s *MonitoringCallbackService) loadMonitoringBrandDailyAggregate(ctx contex
AND cf.business_date = $4::date
AND cf.content_source_type IS NOT NULL
AND cf.content_source_id IS NOT NULL
AND r.ai_platform_id <> 'qwen'
AND r.collector_type = $3
AND r.business_date = $4::date
AND r.status = 'succeeded'
@@ -3454,18 +3454,15 @@ func (s *MonitoringService) loadCitationRanking(
fr.ai_platform_id,
COUNT(cf.id)::bigint AS citation_source_count,
COUNT(cf.id) FILTER (
WHERE fr.ai_platform_id <> 'qwen'
AND cf.content_source_type IS NOT NULL
WHERE cf.content_source_type IS NOT NULL
AND cf.content_source_id IS NOT NULL
)::bigint AS saas_source_count,
COUNT(DISTINCT cf.run_id) FILTER (
WHERE fr.ai_platform_id <> 'qwen'
AND cf.content_source_type IS NOT NULL
WHERE cf.content_source_type IS NOT NULL
AND cf.content_source_id IS NOT NULL
)::bigint AS cited_answer_count,
COUNT(DISTINCT cf.content_source_type || ':' || cf.content_source_id::text) FILTER (
WHERE fr.ai_platform_id <> 'qwen'
AND cf.content_source_type IS NOT NULL
WHERE cf.content_source_type IS NOT NULL
AND cf.content_source_id IS NOT NULL
)::bigint AS cited_article_count
FROM filtered_runs fr
@@ -3658,7 +3655,6 @@ func (s *MonitoringService) loadCitedArticleSourceRows(
AND r.id = cf.run_id
WHERE cf.tenant_id = $1
AND ($2::bigint = 0 OR cf.brand_id = $2)
AND cf.ai_platform_id <> 'qwen'
AND cf.business_date BETWEEN $4::date AND $5::date
AND cf.content_source_type IS NOT NULL
AND cf.content_source_id IS NOT NULL
@@ -4278,7 +4274,7 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
}
}
item.CitationCount++
if platformID != "qwen" && sourceType.Valid && strings.TrimSpace(sourceType.String) != "" && sourceID.Valid {
if sourceType.Valid && strings.TrimSpace(sourceType.String) != "" && sourceID.Valid {
item.ContentCitationCount++
}
aggregates[key] = item
@@ -4324,7 +4320,6 @@ func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, te
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
WHERE r.tenant_id = $1
AND r.status = 'succeeded'
AND r.ai_platform_id <> 'qwen'
AND r.id = ANY($2)
`, tenantID, runIDs)
if err != nil {
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_citation_facts_manual_mark_refresh;
DROP INDEX IF EXISTS idx_citation_facts_manual_mark_sync_candidates;
@@ -0,0 +1,21 @@
CREATE INDEX IF NOT EXISTS idx_citation_facts_manual_mark_sync_candidates
ON monitoring_citation_facts (
tenant_id,
business_date,
registrable_domain,
site_key,
host
)
WHERE content_source_type IS NULL
AND content_source_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_citation_facts_manual_mark_refresh
ON monitoring_citation_facts (
tenant_id,
content_source_type,
content_source_id,
brand_id,
business_date
)
WHERE content_source_type = 'manual_mark'
AND content_source_id IS NOT NULL;
@@ -0,0 +1,53 @@
CREATE TEMP TABLE tmp_manual_mark_backfill_affected ON COMMIT DROP AS
WITH cleared_facts AS (
UPDATE monitoring_citation_facts cf
SET content_source_type = NULL,
content_source_id = NULL
FROM monitoring_user_marked_articles marked
WHERE marked.tenant_id = cf.tenant_id
AND marked.id = cf.content_source_id
AND cf.content_source_type = 'manual_mark'
AND btrim(cf.normalized_url, '/') = btrim(marked.normalized_url, '/')
RETURNING cf.tenant_id, cf.brand_id, cf.business_date
)
SELECT DISTINCT tenant_id, brand_id, business_date
FROM cleared_facts;
WITH citation_counts AS (
SELECT
affected.tenant_id,
affected.brand_id,
affected.business_date,
runs.collector_type,
COUNT(DISTINCT cf.run_id)::int AS cited_answer_count
FROM tmp_manual_mark_backfill_affected affected
JOIN monitoring_citation_facts cf
ON cf.tenant_id = affected.tenant_id
AND cf.brand_id = affected.brand_id
AND cf.business_date = affected.business_date
AND cf.content_source_type IS NOT NULL
AND cf.content_source_id IS NOT NULL
JOIN question_monitor_runs runs
ON runs.tenant_id = cf.tenant_id
AND runs.id = cf.run_id
AND runs.business_date = cf.business_date
AND runs.status = 'succeeded'
GROUP BY affected.tenant_id, affected.brand_id, affected.business_date, runs.collector_type
)
UPDATE monitoring_brand_daily daily
SET cited_answer_count = COALESCE(counts.cited_answer_count, 0),
citation_rate = CASE
WHEN daily.actual_sample_count > 0
THEN COALESCE(counts.cited_answer_count, 0)::numeric / daily.actual_sample_count::numeric
ELSE NULL
END,
updated_at = NOW()
FROM tmp_manual_mark_backfill_affected affected
LEFT JOIN citation_counts counts
ON counts.tenant_id = affected.tenant_id
AND counts.brand_id = affected.brand_id
AND counts.business_date = affected.business_date
WHERE daily.tenant_id = affected.tenant_id
AND daily.brand_id = affected.brand_id
AND daily.business_date = affected.business_date
AND (counts.collector_type IS NULL OR daily.collector_type = counts.collector_type);
@@ -0,0 +1,60 @@
CREATE TEMP TABLE tmp_manual_mark_backfill_affected ON COMMIT DROP AS
WITH matched_facts AS (
SELECT DISTINCT ON (cf.id)
cf.id AS citation_fact_id,
marked.id AS marked_article_id
FROM monitoring_citation_facts cf
JOIN monitoring_user_marked_articles marked
ON marked.tenant_id = cf.tenant_id
AND marked.expires_at > NOW()
WHERE cf.content_source_type IS NULL
AND cf.content_source_id IS NULL
AND cf.business_date >= (CURRENT_DATE - INTERVAL '6 months')::date
AND btrim(cf.normalized_url, '/') = btrim(marked.normalized_url, '/')
ORDER BY cf.id, marked.updated_at DESC, marked.id DESC
),
updated_facts AS (
UPDATE monitoring_citation_facts cf
SET content_source_type = 'manual_mark',
content_source_id = matched.marked_article_id
FROM matched_facts matched
WHERE cf.id = matched.citation_fact_id
RETURNING cf.tenant_id, cf.brand_id, cf.business_date
)
SELECT DISTINCT tenant_id, brand_id, business_date
FROM updated_facts;
WITH citation_counts AS (
SELECT
affected.tenant_id,
affected.brand_id,
affected.business_date,
runs.collector_type,
COUNT(DISTINCT cf.run_id)::int AS cited_answer_count
FROM tmp_manual_mark_backfill_affected affected
JOIN monitoring_citation_facts cf
ON cf.tenant_id = affected.tenant_id
AND cf.brand_id = affected.brand_id
AND cf.business_date = affected.business_date
AND cf.content_source_type IS NOT NULL
AND cf.content_source_id IS NOT NULL
JOIN question_monitor_runs runs
ON runs.tenant_id = cf.tenant_id
AND runs.id = cf.run_id
AND runs.business_date = cf.business_date
AND runs.status = 'succeeded'
GROUP BY affected.tenant_id, affected.brand_id, affected.business_date, runs.collector_type
)
UPDATE monitoring_brand_daily daily
SET cited_answer_count = counts.cited_answer_count,
citation_rate = CASE
WHEN daily.actual_sample_count > 0
THEN counts.cited_answer_count::numeric / daily.actual_sample_count::numeric
ELSE NULL
END,
updated_at = NOW()
FROM citation_counts counts
WHERE daily.tenant_id = counts.tenant_id
AND daily.brand_id = counts.brand_id
AND daily.business_date = counts.business_date
AND daily.collector_type = counts.collector_type;