fix monitoring manual mark attribution
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
import { ArrowLeftOutlined, BookOutlined, CopyOutlined } from '@ant-design/icons-vue'
|
import { ArrowLeftOutlined, BookOutlined, CopyOutlined } from '@ant-design/icons-vue'
|
||||||
import type {
|
import type {
|
||||||
MonitoringQuestionCitationAnalysisItem,
|
MonitoringQuestionCitationAnalysisItem,
|
||||||
|
MonitoringQuestionContentCitation,
|
||||||
MonitoringQuestionDetailCitation,
|
MonitoringQuestionDetailCitation,
|
||||||
MonitoringQuestionDetailPlatform,
|
MonitoringQuestionDetailPlatform,
|
||||||
MonitoringSourceItem,
|
MonitoringSourceItem,
|
||||||
@@ -198,7 +199,10 @@ const activeDeepSeekCitationReferenceLabels = computed<string[][]>(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const activeInlineContentCitations = computed<TrackingAnalyzedContentCitation[]>(() => {
|
const activeInlineContentCitations = computed<TrackingAnalyzedContentCitation[]>(() => {
|
||||||
return analyzeInlineContentCitations(activePlatform.value)
|
return analyzeContentCitations(
|
||||||
|
activePlatform.value,
|
||||||
|
detailQuery.data.value?.content_citations ?? [],
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const shouldShowInlineContentCitations = computed(() => {
|
const shouldShowInlineContentCitations = computed(() => {
|
||||||
@@ -266,7 +270,7 @@ function openImitationCreate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function markCitationAsExternal(citation: MonitoringQuestionDetailCitation): void {
|
function markCitationAsExternal(citation: MonitoringQuestionDetailCitation): void {
|
||||||
if (!canMarkCitationAsExternal(citation, activePlatform.value?.ai_platform_id)) {
|
if (!canMarkCitationAsExternal(citation)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
markCitationMutation.mutate(citation)
|
markCitationMutation.mutate(citation)
|
||||||
@@ -351,9 +355,8 @@ function isManualMarkedCitation(
|
|||||||
|
|
||||||
function canMarkCitationAsExternal(
|
function canMarkCitationAsExternal(
|
||||||
citation: MonitoringQuestionDetailCitation | null | undefined,
|
citation: MonitoringQuestionDetailCitation | null | undefined,
|
||||||
platformId: string | null | undefined,
|
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!citation || !supportsContentCitationDisplay(platformId)) {
|
if (!citation) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!String(citation.cited_url ?? '').trim()) {
|
if (!String(citation.cited_url ?? '').trim()) {
|
||||||
@@ -367,7 +370,7 @@ function canMarkCitationAsExternal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isLinkedInlineCitationPlatform(platformId: string | null | undefined): boolean {
|
function isLinkedInlineCitationPlatform(platformId: string | null | undefined): boolean {
|
||||||
return platformId === 'yuanbao'
|
return platformId === 'yuanbao' || platformId === 'qwen'
|
||||||
}
|
}
|
||||||
|
|
||||||
function isKimiInlineCitationPlatform(platformId: string | null | undefined): boolean {
|
function isKimiInlineCitationPlatform(platformId: string | null | undefined): boolean {
|
||||||
@@ -378,10 +381,6 @@ function isDeepSeekInlineCitationPlatform(platformId: string | null | undefined)
|
|||||||
return platformId === 'deepseek'
|
return platformId === 'deepseek'
|
||||||
}
|
}
|
||||||
|
|
||||||
function supportsContentCitationDisplay(platformId: string | null | undefined): boolean {
|
|
||||||
return platformId !== 'qwen'
|
|
||||||
}
|
|
||||||
|
|
||||||
function createQwenSourceGroupPattern(): RegExp {
|
function createQwenSourceGroupPattern(): RegExp {
|
||||||
return /\[\[source_group_web_(\d+)\]\]/g
|
return /\[\[source_group_web_(\d+)\]\]/g
|
||||||
}
|
}
|
||||||
@@ -1140,6 +1139,36 @@ function collectDeepSeekInlineCitationOccurrences(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function analyzeContentCitations(
|
||||||
|
platform: MonitoringQuestionDetailPlatform | null,
|
||||||
|
contentCitations: MonitoringQuestionContentCitation[],
|
||||||
|
): TrackingAnalyzedContentCitation[] {
|
||||||
|
const platformId = normalizeMonitoringPlatformId(platform?.ai_platform_id)
|
||||||
|
const serverItems = contentCitations
|
||||||
|
.filter((item) => normalizeMonitoringPlatformId(item.ai_platform_id) === platformId)
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (right.citation_count !== left.citation_count) {
|
||||||
|
return right.citation_count - left.citation_count
|
||||||
|
}
|
||||||
|
return left.article_title.localeCompare(right.article_title, 'zh-CN')
|
||||||
|
})
|
||||||
|
.map((item) => ({
|
||||||
|
key: `server-${item.ai_platform_id}-${item.source_type}-${item.source_id}`,
|
||||||
|
contentName: item.article_title || item.source_url || '--',
|
||||||
|
channelName: item.publish_platform || deriveCitationChannelName(item.source_url),
|
||||||
|
citationCount: item.citation_count,
|
||||||
|
citationRate: item.citation_rate,
|
||||||
|
citedURL: item.source_url || null,
|
||||||
|
sourceType: item.source_type,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (serverItems.length > 0) {
|
||||||
|
return serverItems
|
||||||
|
}
|
||||||
|
|
||||||
|
return analyzeInlineContentCitations(platform)
|
||||||
|
}
|
||||||
|
|
||||||
function analyzeInlineContentCitations(
|
function analyzeInlineContentCitations(
|
||||||
platform: MonitoringQuestionDetailPlatform | null,
|
platform: MonitoringQuestionDetailPlatform | null,
|
||||||
): TrackingAnalyzedContentCitation[] {
|
): TrackingAnalyzedContentCitation[] {
|
||||||
@@ -1563,7 +1592,7 @@ const contentCitationColumns = computed(() => [
|
|||||||
</span>
|
</span>
|
||||||
<div class="tracking-question-source-card__actions">
|
<div class="tracking-question-source-card__actions">
|
||||||
<a-tooltip
|
<a-tooltip
|
||||||
v-if="canMarkCitationAsExternal(citation, activePlatform?.ai_platform_id)"
|
v-if="canMarkCitationAsExternal(citation)"
|
||||||
:title="t('tracking.markAsExternalArticle')"
|
:title="t('tracking.markAsExternalArticle')"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -1592,20 +1621,14 @@ const contentCitationColumns = computed(() => [
|
|||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<a-tag
|
<a-tag
|
||||||
v-if="
|
v-if="citation.article_id"
|
||||||
citation.article_id &&
|
|
||||||
supportsContentCitationDisplay(activePlatform?.ai_platform_id)
|
|
||||||
"
|
|
||||||
color="blue"
|
color="blue"
|
||||||
class="tracking-question-source-card__badge"
|
class="tracking-question-source-card__badge"
|
||||||
>
|
>
|
||||||
{{ t('tracking.contentCitationBadge') }}
|
{{ t('tracking.contentCitationBadge') }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
<a-tag
|
<a-tag
|
||||||
v-else-if="
|
v-else-if="isManualMarkedCitation(citation)"
|
||||||
isManualMarkedCitation(citation) &&
|
|
||||||
supportsContentCitationDisplay(activePlatform?.ai_platform_id)
|
|
||||||
"
|
|
||||||
color="green"
|
color="green"
|
||||||
class="tracking-question-source-card__badge"
|
class="tracking-question-source-card__badge"
|
||||||
>
|
>
|
||||||
@@ -1654,10 +1677,7 @@ const contentCitationColumns = computed(() => [
|
|||||||
{{ record.site_name }}
|
{{ record.site_name }}
|
||||||
</strong>
|
</strong>
|
||||||
<small
|
<small
|
||||||
v-if="
|
v-if="record.content_citation_count > 0"
|
||||||
record.content_citation_count > 0 &&
|
|
||||||
supportsContentCitationDisplay(record.ai_platform_id)
|
|
||||||
"
|
|
||||||
class="meta-subtext"
|
class="meta-subtext"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
|
|||||||
@@ -3213,11 +3213,11 @@ func (s *MonitoringCallbackService) loadExistingRunSummary(ctx context.Context,
|
|||||||
if err := s.monitoringPool.QueryRow(ctx, `
|
if err := s.monitoringPool.QueryRow(ctx, `
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*)::int AS citation_source_count,
|
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
|
FROM monitoring_citation_facts
|
||||||
WHERE tenant_id = $1
|
WHERE tenant_id = $1
|
||||||
AND run_id = $2
|
AND run_id = $2
|
||||||
`, task.TenantID, runID.Int64, normalizeMonitoringPlatformID(task.AIPlatformID)).Scan(&citationSourceCount, &contentCitationCount); err != nil {
|
`, 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")
|
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 {
|
func monitoringPlatformResolvesContentCitations(platformID string) bool {
|
||||||
switch normalizeMonitoringPlatformID(platformID) {
|
return normalizeMonitoringPlatformID(platformID) != ""
|
||||||
case "qwen":
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMonitoringTaskResultEnvelope(task *monitoringCollectTask, installationID string, receivedAt time.Time, req MonitoringTaskResultRequest) monitoringTaskResultEnvelope {
|
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)
|
aliasArticleID := int64(42)
|
||||||
aliasPublishRecordID := int64(84)
|
aliasPublishRecordID := int64(84)
|
||||||
normalizedURL := "https://example.com/article"
|
normalizedURL := "https://example.com/article"
|
||||||
@@ -561,15 +571,15 @@ func TestBuildMonitoringCitationFactCanDisableContentResolution(t *testing.T) {
|
|||||||
t.Fatalf("resolved PublishRecordID = %v, want %d", resolvedFact.PublishRecordID, aliasPublishRecordID)
|
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 {
|
if !ok {
|
||||||
t.Fatal("buildMonitoringCitationFact() without content resolution returned !ok")
|
t.Fatal("buildMonitoringCitationFact() for qwen returned !ok")
|
||||||
}
|
}
|
||||||
if sourceOnlyFact.ArticleID != nil {
|
if sourceOnlyFact.ArticleID == nil || *sourceOnlyFact.ArticleID != aliasArticleID {
|
||||||
t.Fatalf("source-only ArticleID = %v, want nil", sourceOnlyFact.ArticleID)
|
t.Fatalf("qwen ArticleID = %v, want %d", sourceOnlyFact.ArticleID, aliasArticleID)
|
||||||
}
|
}
|
||||||
if sourceOnlyFact.PublishRecordID != nil {
|
if sourceOnlyFact.PublishRecordID == nil || *sourceOnlyFact.PublishRecordID != aliasPublishRecordID {
|
||||||
t.Fatalf("source-only PublishRecordID = %v, want nil", sourceOnlyFact.PublishRecordID)
|
t.Fatalf("qwen PublishRecordID = %v, want %d", sourceOnlyFact.PublishRecordID, aliasPublishRecordID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"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/auth"
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
@@ -61,6 +63,11 @@ type monitoringMarkedArticleURLMeta struct {
|
|||||||
LastPathSegment *string
|
LastPathSegment *string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type monitoringProjectionRefreshScope struct {
|
||||||
|
BrandID int64
|
||||||
|
BusinessDate time.Time
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MonitoringService) ListMarkedArticles(ctx context.Context, params MonitoringMarkedArticleListParams) (*MonitoringMarkedArticleListResponse, error) {
|
func (s *MonitoringService) ListMarkedArticles(ctx context.Context, params MonitoringMarkedArticleListParams) (*MonitoringMarkedArticleListResponse, error) {
|
||||||
actor := auth.MustActor(ctx)
|
actor := auth.MustActor(ctx)
|
||||||
pageReq := normalizeMonitoringPageRequest(MonitoringPageRequest{
|
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")
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
s.refreshMarkedArticleProjectionScopes(ctx, actor.TenantID, scopes)
|
||||||
|
|
||||||
return s.GetMarkedArticle(ctx, id)
|
return s.GetMarkedArticle(ctx, id)
|
||||||
}
|
}
|
||||||
@@ -260,14 +269,20 @@ func (s *MonitoringService) UpdateMarkedArticle(ctx context.Context, id int64, r
|
|||||||
if tag.RowsAffected() == 0 {
|
if tag.RowsAffected() == 0 {
|
||||||
return nil, response.ErrNotFound(40441, "marked_article_not_found", "marked article not found")
|
return nil, response.ErrNotFound(40441, "marked_article_not_found", "marked article not found")
|
||||||
}
|
}
|
||||||
|
scopes := make([]monitoringProjectionRefreshScope, 0)
|
||||||
if oldNormalizedURL != "" && oldNormalizedURL != meta.NormalizedURL {
|
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
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
scopes = append(scopes, syncedScopes...)
|
||||||
|
s.refreshMarkedArticleProjectionScopes(ctx, actor.TenantID, scopes)
|
||||||
return s.GetMarkedArticle(ctx, id)
|
return s.GetMarkedArticle(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,9 +312,11 @@ func (s *MonitoringService) DeleteMarkedArticle(ctx context.Context, id int64) e
|
|||||||
if tag.RowsAffected() == 0 {
|
if tag.RowsAffected() == 0 {
|
||||||
return response.ErrNotFound(40441, "marked_article_not_found", "marked article not found")
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
s.refreshMarkedArticleProjectionScopes(ctx, actor.TenantID, scopes)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,27 +448,95 @@ func normalizeMarkedArticleDomain(value string) string {
|
|||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MonitoringService) syncMarkedArticleCitationFacts(ctx context.Context, tenantID, markedArticleID int64, normalizedURL string) error {
|
func (s *MonitoringService) syncMarkedArticleCitationFacts(ctx context.Context, tenantID, markedArticleID int64, meta monitoringMarkedArticleURLMeta) ([]monitoringProjectionRefreshScope, error) {
|
||||||
if tenantID <= 0 || markedArticleID <= 0 || strings.TrimSpace(normalizedURL) == "" {
|
if tenantID <= 0 || markedArticleID <= 0 || strings.TrimSpace(meta.NormalizedURL) == "" {
|
||||||
return nil
|
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, `
|
if _, err := s.monitoringPool.Exec(ctx, `
|
||||||
UPDATE monitoring_citation_facts
|
UPDATE monitoring_citation_facts
|
||||||
SET content_source_type = 'manual_mark',
|
SET content_source_type = 'manual_mark',
|
||||||
content_source_id = $3
|
content_source_id = $3
|
||||||
WHERE tenant_id = $1
|
WHERE tenant_id = $1
|
||||||
AND normalized_url = $2
|
AND id = ANY($2::bigint[])
|
||||||
AND content_source_type IS NULL
|
AND content_source_type IS NULL
|
||||||
AND content_source_id IS NULL
|
AND content_source_id IS NULL
|
||||||
`, tenantID, strings.TrimSpace(normalizedURL), markedArticleID); err != nil {
|
`, tenantID, matches, markedArticleID); err != nil {
|
||||||
return response.ErrInternal(50041, "marked_article_citation_sync_failed", "failed to sync marked article citations")
|
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 {
|
if tenantID <= 0 || markedArticleID <= 0 {
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
args := []interface{}{tenantID, markedArticleID}
|
args := []interface{}{tenantID, markedArticleID}
|
||||||
whereURL := ""
|
whereURL := ""
|
||||||
@@ -459,17 +544,147 @@ func (s *MonitoringService) clearMarkedArticleCitationFacts(ctx context.Context,
|
|||||||
args = append(args, trimmed)
|
args = append(args, trimmed)
|
||||||
whereURL = " AND normalized_url = $3"
|
whereURL = " AND normalized_url = $3"
|
||||||
}
|
}
|
||||||
if _, err := s.monitoringPool.Exec(ctx, `
|
rows, err := s.monitoringPool.Query(ctx, `
|
||||||
UPDATE monitoring_citation_facts
|
UPDATE monitoring_citation_facts
|
||||||
SET content_source_type = NULL,
|
SET content_source_type = NULL,
|
||||||
content_source_id = NULL
|
content_source_id = NULL
|
||||||
WHERE tenant_id = $1
|
WHERE tenant_id = $1
|
||||||
AND content_source_type = 'manual_mark'
|
AND content_source_type = 'manual_mark'
|
||||||
AND content_source_id = $2
|
AND content_source_id = $2
|
||||||
`+whereURL, args...); err != nil {
|
`+whereURL+`
|
||||||
return response.ErrInternal(50041, "marked_article_citation_clear_failed", "failed to clear marked article citations")
|
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{} {
|
func nullableTimePtr(value *time.Time) interface{} {
|
||||||
|
|||||||
@@ -216,7 +216,6 @@ func (s *MonitoringCallbackService) loadMonitoringBrandDailyAggregate(ctx contex
|
|||||||
AND cf.business_date = $4::date
|
AND cf.business_date = $4::date
|
||||||
AND cf.content_source_type IS NOT NULL
|
AND cf.content_source_type IS NOT NULL
|
||||||
AND cf.content_source_id IS NOT NULL
|
AND cf.content_source_id IS NOT NULL
|
||||||
AND r.ai_platform_id <> 'qwen'
|
|
||||||
AND r.collector_type = $3
|
AND r.collector_type = $3
|
||||||
AND r.business_date = $4::date
|
AND r.business_date = $4::date
|
||||||
AND r.status = 'succeeded'
|
AND r.status = 'succeeded'
|
||||||
|
|||||||
@@ -3454,18 +3454,15 @@ func (s *MonitoringService) loadCitationRanking(
|
|||||||
fr.ai_platform_id,
|
fr.ai_platform_id,
|
||||||
COUNT(cf.id)::bigint AS citation_source_count,
|
COUNT(cf.id)::bigint AS citation_source_count,
|
||||||
COUNT(cf.id) FILTER (
|
COUNT(cf.id) FILTER (
|
||||||
WHERE fr.ai_platform_id <> 'qwen'
|
WHERE cf.content_source_type IS NOT NULL
|
||||||
AND cf.content_source_type IS NOT NULL
|
|
||||||
AND cf.content_source_id IS NOT NULL
|
AND cf.content_source_id IS NOT NULL
|
||||||
)::bigint AS saas_source_count,
|
)::bigint AS saas_source_count,
|
||||||
COUNT(DISTINCT cf.run_id) FILTER (
|
COUNT(DISTINCT cf.run_id) FILTER (
|
||||||
WHERE fr.ai_platform_id <> 'qwen'
|
WHERE cf.content_source_type IS NOT NULL
|
||||||
AND cf.content_source_type IS NOT NULL
|
|
||||||
AND cf.content_source_id IS NOT NULL
|
AND cf.content_source_id IS NOT NULL
|
||||||
)::bigint AS cited_answer_count,
|
)::bigint AS cited_answer_count,
|
||||||
COUNT(DISTINCT cf.content_source_type || ':' || cf.content_source_id::text) FILTER (
|
COUNT(DISTINCT cf.content_source_type || ':' || cf.content_source_id::text) FILTER (
|
||||||
WHERE fr.ai_platform_id <> 'qwen'
|
WHERE cf.content_source_type IS NOT NULL
|
||||||
AND cf.content_source_type IS NOT NULL
|
|
||||||
AND cf.content_source_id IS NOT NULL
|
AND cf.content_source_id IS NOT NULL
|
||||||
)::bigint AS cited_article_count
|
)::bigint AS cited_article_count
|
||||||
FROM filtered_runs fr
|
FROM filtered_runs fr
|
||||||
@@ -3658,7 +3655,6 @@ func (s *MonitoringService) loadCitedArticleSourceRows(
|
|||||||
AND r.id = cf.run_id
|
AND r.id = cf.run_id
|
||||||
WHERE cf.tenant_id = $1
|
WHERE cf.tenant_id = $1
|
||||||
AND ($2::bigint = 0 OR cf.brand_id = $2)
|
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.business_date BETWEEN $4::date AND $5::date
|
||||||
AND cf.content_source_type IS NOT NULL
|
AND cf.content_source_type IS NOT NULL
|
||||||
AND cf.content_source_id 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++
|
item.CitationCount++
|
||||||
if platformID != "qwen" && sourceType.Valid && strings.TrimSpace(sourceType.String) != "" && sourceID.Valid {
|
if sourceType.Valid && strings.TrimSpace(sourceType.String) != "" && sourceID.Valid {
|
||||||
item.ContentCitationCount++
|
item.ContentCitationCount++
|
||||||
}
|
}
|
||||||
aggregates[key] = item
|
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
|
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
|
||||||
WHERE r.tenant_id = $1
|
WHERE r.tenant_id = $1
|
||||||
AND r.status = 'succeeded'
|
AND r.status = 'succeeded'
|
||||||
AND r.ai_platform_id <> 'qwen'
|
|
||||||
AND r.id = ANY($2)
|
AND r.id = ANY($2)
|
||||||
`, tenantID, runIDs)
|
`, tenantID, runIDs)
|
||||||
if err != nil {
|
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;
|
||||||
+53
@@ -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);
|
||||||
+60
@@ -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;
|
||||||
Reference in New Issue
Block a user