From 4f4f06320087a443fcd855a0b10c23cde1d3e4da Mon Sep 17 00:00:00 2001 From: liangxu Date: Thu, 23 Apr 2026 09:11:53 +0800 Subject: [PATCH] refactor(monitoring): simplify brand dashboard and harden citation rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drop brand_time_buckets and sparkline/coverage UI; dashboard now returns a single day - compute mention rate from total actual samples instead of averaging per-platform rates - canonicalize ai_platform_id across services and aggregate platform-overview snapshots - strip mojibake and non-answer noise from callback payloads and question detail answers - keep Kimi citations panel-only; still store raw search_results for audit - render linked [n] inline citations for qwen/yuanbao answers with scroll-to-source behavior - restrict立即采集 to today's business date with a clear i18n hint - refresh content-citations into an ant-design table with truncation and hover styling Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/admin-web/src/i18n/messages/en-US.ts | 1 + apps/admin-web/src/i18n/messages/zh-CN.ts | 1 + .../admin-web/src/lib/monitoring-platforms.ts | 22 + .../src/views/TrackingQuestionDetailView.vue | 681 +++++++++++---- apps/admin-web/src/views/TrackingView.vue | 245 ++---- packages/shared-types/src/index.ts | 18 +- .../tenant/app/monitoring_callback_service.go | 45 +- .../app/monitoring_callback_service_test.go | 102 +++ .../tenant/app/monitoring_projection.go | 27 +- .../internal/tenant/app/monitoring_service.go | 789 ++++++++++-------- .../app/monitoring_service_date_test.go | 65 ++ .../tenant/app/monitoring_text_encoding.go | 196 +++++ .../app/monitoring_text_encoding_test.go | 21 + 13 files changed, 1469 insertions(+), 744 deletions(-) create mode 100644 apps/admin-web/src/lib/monitoring-platforms.ts create mode 100644 server/internal/tenant/app/monitoring_callback_service_test.go create mode 100644 server/internal/tenant/app/monitoring_text_encoding.go create mode 100644 server/internal/tenant/app/monitoring_text_encoding_test.go diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index 12fe394..91003b7 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -348,6 +348,7 @@ const enUS = { collectNow: "Collect Now", collectBrandRequired: "Select a brand before collecting.", collectKeywordRequired: "Select a keyword before collecting the current question set.", + collectTodayOnly: "Collect Now only supports today's data. Switch back to today to continue.", collectClientChecking: "Checking whether the current account's desktop client is online.", collectClientOnlineRequired: "The current account's desktop client must be online before collecting.", pluginMode: "Plugin sampling", diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index 5d9cc0c..9ad209b 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -348,6 +348,7 @@ const zhCN = { collectNow: "立即采集", collectBrandRequired: "请先选择品牌,再发起采集", collectKeywordRequired: "请先选择关键词,再立即采集当前问题", + collectTodayOnly: "立即采集只支持今天的数据,请切回今天后再操作", collectClientChecking: "正在检查当前账号的桌面客户端状态", collectClientOnlineRequired: "当前登录账号的桌面客户端未在线,暂时不能立即采集", pluginMode: "插件采样", diff --git a/apps/admin-web/src/lib/monitoring-platforms.ts b/apps/admin-web/src/lib/monitoring-platforms.ts new file mode 100644 index 0000000..d8f9fb7 --- /dev/null +++ b/apps/admin-web/src/lib/monitoring-platforms.ts @@ -0,0 +1,22 @@ +import { aiPlatformCatalog } from "@geo/shared-types"; + +const monitoringPlatformIds = new Set(aiPlatformCatalog.map((item) => item.id)); + +export function normalizeMonitoringPlatformId(platformId?: string | null): string { + return String(platformId ?? "").trim().toLowerCase(); +} + +export function isMonitoringPlatformId(platformId?: string | null): boolean { + const normalized = normalizeMonitoringPlatformId(platformId); + return Boolean(normalized) && monitoringPlatformIds.has(normalized); +} + +export function normalizeMonitoringPlatformFilter(value: unknown): string { + const normalized = normalizeMonitoringPlatformId( + String(Array.isArray(value) ? value[0] ?? "" : value ?? ""), + ); + if (!normalized) { + return "all"; + } + return monitoringPlatformIds.has(normalized) ? normalized : "all"; +} diff --git a/apps/admin-web/src/views/TrackingQuestionDetailView.vue b/apps/admin-web/src/views/TrackingQuestionDetailView.vue index 4f70de0..1b68d49 100644 --- a/apps/admin-web/src/views/TrackingQuestionDetailView.vue +++ b/apps/admin-web/src/views/TrackingQuestionDetailView.vue @@ -6,8 +6,9 @@ import type { MonitoringQuestionCitationAnalysisItem, MonitoringQuestionDetailCitation, MonitoringQuestionDetailPlatform, + MonitoringSourceItem, } from "@geo/shared-types"; -import { computed, ref, watch } from "vue"; +import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; import { useRoute, useRouter } from "vue-router"; @@ -15,6 +16,7 @@ import MarkdownPreview from "@/components/MarkdownPreview.vue"; import { monitoringApi } from "@/lib/api"; import { formatDateTime } from "@/lib/display"; import { formatError } from "@/lib/errors"; +import { isMonitoringPlatformId, normalizeMonitoringPlatformId } from "@/lib/monitoring-platforms"; const { t } = useI18n(); const route = useRoute(); @@ -28,7 +30,10 @@ const questionId = computed(() => parsePositiveNumber(route.params.questionId)); const questionHash = computed(() => normalizeQueryValue(route.query.question_hash)); const questionTitleFallback = computed(() => normalizeQueryValue(route.query.question_text)); const keywordId = computed(() => normalizeQueryValue(route.query.keyword_id)); -const requestedPlatformId = computed(() => normalizeQueryValue(route.query.ai_platform_id)); +const requestedPlatformId = computed(() => { + const platformId = normalizeMonitoringPlatformId(normalizeQueryValue(route.query.ai_platform_id)); + return isMonitoringPlatformId(platformId) ? platformId : ""; +}); const businessDate = computed(() => normalizeTrackingBusinessDate(route.query.business_date) || trackingToday); const dateFrom = computed(() => { return normalizeQueryValue(route.query.date_from) || businessDate.value; @@ -59,9 +64,15 @@ const detailQuery = useQuery({ }); const activePlatformId = ref(""); +const detailPlatforms = computed(() => detailQuery.data.value?.platforms ?? []); +const detailCitationAnalysis = computed(() => detailQuery.data.value?.citation_analysis ?? []); +const citationSourcesScrollRef = ref(null); +const highlightedCitationIndex = ref(null); + +let citationHighlightTimer: ReturnType | null = null; watch( - () => detailQuery.data.value?.platforms, + detailPlatforms, (platforms) => { if (!platforms?.length) { activePlatformId.value = ""; @@ -85,12 +96,10 @@ const pageTitle = computed(() => { return detailQuery.data.value?.question_text ?? questionTitleFallback.value ?? t("tracking.questionDetailFallback"); }); -const activePlatform = computed(() => { - return detailQuery.data.value?.platforms.find((item) => item.ai_platform_id === activePlatformId.value) ?? null; -}); +const activePlatform = computed(() => detailPlatforms.value.find((item) => item.ai_platform_id === activePlatformId.value) ?? null); const activeCitationAnalysis = computed(() => { - const items = detailQuery.data.value?.citation_analysis ?? []; + const items = detailCitationAnalysis.value; if (!activePlatformId.value) { return items; } @@ -98,7 +107,7 @@ const activeCitationAnalysis = computed(() => { - const platforms = detailQuery.data.value?.platforms ?? []; + const platforms = detailPlatforms.value; const sampledAtValues = platforms .map((item) => item.sampled_at) .filter((value): value is string => Boolean(value)); @@ -129,8 +138,8 @@ type TrackingAnalyzedContentCitation = { citedURL: string | null; }; -const sanitizedAnswerText = computed(() => { - return stripAnswerCitationMarkers(activePlatform.value?.answer_text); +const renderedAnswerContent = computed(() => { + return formatAnswerContent(activePlatform.value); }); const activeInlineContentCitations = computed(() => { @@ -141,6 +150,14 @@ const shouldShowInlineContentCitations = computed(() => { return activeInlineContentCitations.value.length > 0; }); +watch(activePlatformId, () => { + clearCitationHighlight(); +}); + +onBeforeUnmount(() => { + clearCitationHighlight(); +}); + function goBack(): void { void router.push({ name: "tracking", @@ -183,24 +200,198 @@ function resolveCitationTitle(citation: MonitoringQuestionDetailCitation): strin return citation.cited_title || citation.article_title || citation.cited_url; } +function isLinkedInlineCitationPlatform(platformId: string | null | undefined): boolean { + return platformId === "qwen" || platformId === "yuanbao"; +} + +function isKimiInlineCitationPlatform(platformId: string | null | undefined): boolean { + return platformId === "kimi"; +} + function createQwenSourceGroupPattern(): RegExp { return /\[\[source_group_web_(\d+)\]\]/g; } -function stripAnswerCitationMarkers(answerText: string | null | undefined): string | null { +function createYuanbaoCitationPattern(): RegExp { + return /\[citation:\s*(\d+)\]/gi; +} + +function createYuanbaoCitationStripPattern(): RegExp { + return /\[\s*citation\s*:[^\[\]]*?\]/gi; +} + +function createQwenSourceGroupStripPattern(): RegExp { + return /\[\[\s*source_group_web_[^\[\]]*?\]\]/gi; +} + +function createMarkdownLinkPattern(): RegExp { + return /\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g; +} + +function collectInlineCitationIndexes(answerText: string): number[] { + const matches = [ + ...answerText.matchAll(createQwenSourceGroupPattern()), + ...answerText.matchAll(createYuanbaoCitationPattern()), + ]; + + return matches + .map((match) => Number(match[1])) + .filter((value) => Number.isFinite(value) && value > 0); +} + +function normalizeInlineCitationUrl(value: string | null | undefined): string { + const normalized = String(value ?? "").trim(); + if (!normalized) { + return ""; + } + + try { + const url = new URL(normalized); + url.hash = ""; + return url.toString(); + } catch { + return normalized; + } +} + +function deriveCitationChannelName(url: string): string { + try { + return new URL(url).hostname || "--"; + } catch { + return "--"; + } +} + +function collectKimiInlineCitationOccurrences(answerText: string): Array<{ + url: string; + label: string | null; + citationCount: number; +}> { + const counts = new Map(); + const pattern = createMarkdownLinkPattern(); + + for (const match of answerText.matchAll(pattern)) { + const matchIndex = match.index ?? 0; + if (matchIndex > 0 && answerText[matchIndex - 1] === "!") { + continue; + } + + const label = String(match[1] ?? "").trim() || null; + const url = normalizeInlineCitationUrl(match[2]); + if (!url) { + continue; + } + + const existing = counts.get(url); + if (existing) { + counts.set(url, { + label: existing.label ?? label, + citationCount: existing.citationCount + 1, + }); + continue; + } + + counts.set(url, { + label, + citationCount: 1, + }); + } + + return Array.from(counts.entries()).map(([url, value]) => ({ + url, + label: value.label, + citationCount: value.citationCount, + })); +} + +function buildInlineCitationSourceLookup(platform: MonitoringQuestionDetailPlatform | null): Map { + const lookup = new Map(); + for (const item of platform?.inline_citations ?? []) { + const normalizedURL = normalizeInlineCitationUrl(item.normalized_url ?? item.url); + if (!normalizedURL || lookup.has(normalizedURL)) { + continue; + } + lookup.set(normalizedURL, item); + } + return lookup; +} + +function countHanCharacters(value: string): number { + return value.match(/[\u3400-\u9fff]/g)?.length ?? 0; +} + +function countSuspiciousMojibakeCharacters(value: string): number { + return value.match(/[\u00c0-\u017f\u2000-\u20ff]/g)?.length ?? 0; +} + +function looksLikeNonAnswerNoise(value: string): boolean { + const hanCount = countHanCharacters(value); + const suspiciousCount = countSuspiciousMojibakeCharacters(value); + if (hanCount === 0) { + return suspiciousCount >= 4; + } + return suspiciousCount >= 8 && suspiciousCount > hanCount * 3; +} + +function sanitizeAnswerBody(answerText: string | null | undefined): string | null { const normalized = String(answerText ?? "").trim(); if (!normalized) { return null; } - const cleaned = normalized - .replace(createQwenSourceGroupPattern(), "") + const lines = normalized.split(/\r?\n+/).map((line) => line.trim()).filter(Boolean); + const keptLines = lines.filter((line) => !looksLikeNonAnswerNoise(line)); + const sanitized = (keptLines.length ? keptLines.join("\n") : normalized) .replace(/[ \t]+([,.;:!?,。!?;:])/g, "$1") .replace(/[ \t]{2,}/g, " ") .replace(/\n{3,}/g, "\n\n") .trim(); - return cleaned || null; + return sanitized || null; +} + +function buildInlineCitationMarker( + sourceGroupIndex: number, + platform: MonitoringQuestionDetailPlatform | null, +): string { + if (!Number.isFinite(sourceGroupIndex) || sourceGroupIndex < 1) { + return ""; + } + + const hasCitation = Boolean(platform?.citations[sourceGroupIndex - 1]); + const className = hasCitation + ? "tracking-inline-citation" + : "tracking-inline-citation tracking-inline-citation--muted"; + + if (!hasCitation) { + return `[${sourceGroupIndex}]`; + } + + return ``; +} + +function formatAnswerContent(platform: MonitoringQuestionDetailPlatform | null): string | null { + const sanitized = sanitizeAnswerBody(platform?.answer_text); + if (!sanitized) { + return null; + } + + const withInlineMarkers = isLinkedInlineCitationPlatform(platform?.ai_platform_id) + ? sanitized + .replace(createQwenSourceGroupPattern(), (_, sourceGroupIndex) => { + return buildInlineCitationMarker(Number(sourceGroupIndex), platform); + }) + .replace(createYuanbaoCitationPattern(), (_, sourceGroupIndex) => { + return buildInlineCitationMarker(Number(sourceGroupIndex), platform); + }) + : sanitized; + + return withInlineMarkers + .replace(createQwenSourceGroupStripPattern(), "") + .replace(createYuanbaoCitationStripPattern(), "") + .replace(/[ \t]{2,}/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim(); } function analyzeInlineContentCitations( @@ -211,17 +402,47 @@ function analyzeInlineContentCitations( return []; } - const matches = Array.from(answerText.matchAll(createQwenSourceGroupPattern())); - if (!matches.length) { + if (isKimiInlineCitationPlatform(platform?.ai_platform_id)) { + const occurrences = collectKimiInlineCitationOccurrences(answerText); + if (!occurrences.length) { + return []; + } + + const totalCitationCount = occurrences.reduce((sum, item) => sum + item.citationCount, 0); + if (!totalCitationCount) { + return []; + } + + const inlineCitationLookup = buildInlineCitationSourceLookup(platform); + return occurrences + .sort((left, right) => { + if (right.citationCount !== left.citationCount) { + return right.citationCount - left.citationCount; + } + return left.url.localeCompare(right.url, "zh-CN"); + }) + .map((item) => { + const source = inlineCitationLookup.get(item.url); + const contentName = source?.title?.trim() || item.label || item.url; + const channelName = source?.site_name?.trim() || deriveCitationChannelName(item.url); + return { + key: `kimi-${item.url}`, + contentName, + channelName, + citationCount: item.citationCount, + citationRate: item.citationCount / totalCitationCount, + citedURL: item.url, + }; + }); + } + + const sourceGroupIndexes = collectInlineCitationIndexes(answerText); + if (!sourceGroupIndexes.length) { return []; } const citationCounts = new Map(); - for (const match of matches) { - const sourceGroupIndex = Number(match[1]); - if (!Number.isFinite(sourceGroupIndex) || sourceGroupIndex <= 0) { - continue; - } + for (const sourceGroupIndex of sourceGroupIndexes) { citationCounts.set(sourceGroupIndex, (citationCounts.get(sourceGroupIndex) ?? 0) + 1); } @@ -237,20 +458,81 @@ function analyzeInlineContentCitations( } return left[0] - right[0]; }) - .map(([sourceGroupIndex, citationCount]) => { + .flatMap(([sourceGroupIndex, citationCount]) => { const citation = platform?.citations[sourceGroupIndex - 1] ?? null; + const citedURL = citation?.cited_url?.trim() ?? ""; + if (!citedURL) { + return []; + } - return { - key: `${sourceGroupIndex}-${citation?.cited_url ?? `source_group_web_${sourceGroupIndex}`}`, + return [{ + key: `${sourceGroupIndex}-${citedURL}`, contentName: citation ? resolveCitationTitle(citation) : `引用内容 #${sourceGroupIndex}`, channelName: citation?.site_name ?? "--", citationCount, citationRate: citationCount / totalCitationCount, - citedURL: citation?.cited_url ?? null, - }; + citedURL, + }]; }); } +function clearCitationHighlight(): void { + if (citationHighlightTimer !== null) { + clearTimeout(citationHighlightTimer); + citationHighlightTimer = null; + } + highlightedCitationIndex.value = null; +} + +async function focusCitationSource(sourceGroupIndex: number): Promise { + if (!Number.isFinite(sourceGroupIndex) || sourceGroupIndex < 1) { + return; + } + + await nextTick(); + const citationCard = citationSourcesScrollRef.value?.querySelector( + `[data-citation-index="${sourceGroupIndex}"]`, + ); + if (!citationCard) { + return; + } + + highlightedCitationIndex.value = sourceGroupIndex; + citationCard.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "nearest", + }); + + if (citationHighlightTimer !== null) { + clearTimeout(citationHighlightTimer); + } + citationHighlightTimer = setTimeout(() => { + highlightedCitationIndex.value = null; + citationHighlightTimer = null; + }, 2400); +} + +function handleAnswerContentClick(event: MouseEvent): void { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + const trigger = target.closest("[data-citation-index]") as HTMLElement | null; + if (!trigger) { + return; + } + + const sourceGroupIndex = Number(trigger.dataset.citationIndex ?? ""); + if (!Number.isFinite(sourceGroupIndex) || sourceGroupIndex < 1) { + return; + } + + event.preventDefault(); + void focusCitationSource(sourceGroupIndex); +} + function normalizeQueryValue(value: unknown): string { const raw = Array.isArray(value) ? value[0] : value; return String(raw ?? "").trim(); @@ -282,6 +564,20 @@ function parsePositiveNumber(value: unknown): number | null { } return parsed; } + +const citationAnalysisColumns = computed(() => [ + { title: t("tracking.columns.sourcePlatform"), key: "sourcePlatform", dataIndex: "site_name", width: "30%" }, + { title: t("tracking.columns.domain"), key: "domain", dataIndex: "site_domain", width: "30%", align: "center" as const }, + { title: t("tracking.columns.citations"), key: "citationCount", dataIndex: "citation_count", align: "center" as const, width: "20%" }, + { title: t("tracking.columns.share"), key: "citationRate", dataIndex: "citation_rate", align: "center" as const, width: "20%" }, +]); + +const contentCitationColumns = computed(() => [ + { title: t("tracking.columns.contentName"), key: "contentName", dataIndex: "contentName", width: "40%" }, + { title: t("tracking.columns.channelName"), key: "channelName", dataIndex: "channelName", width: "20%", align: "center" as const }, + { title: t("tracking.columns.citations"), key: "citationCount", dataIndex: "citationCount", align: "center" as const, width: "20%" }, + { title: t("tracking.columns.share"), key: "citationRate", dataIndex: "citationRate", align: "center" as const, width: "20%" }, +]);