refactor(monitoring): simplify brand dashboard and harden citation rendering

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 09:11:53 +08:00
parent ce028c56bf
commit 4f4f063200
13 changed files with 1469 additions and 744 deletions
@@ -1683,6 +1683,8 @@ func (s *MonitoringCallbackService) upsertParseResult(ctx context.Context, tx pg
}
func (s *MonitoringCallbackService) processTaskResult(ctx context.Context, task *monitoringCollectTask, req MonitoringTaskResultRequest) (*MonitoringTaskResultResponse, error) {
req = normalizeMonitoringTaskResultRequestValue(req)
runStatus := normalizeRunStatus(req.Status)
if runStatus == "" {
return nil, response.ErrBadRequest(40041, "invalid_status", "status must be succeeded or failed")
@@ -1703,7 +1705,7 @@ func (s *MonitoringCallbackService) processTaskResult(ctx context.Context, task
completedAt = req.CompletedAt.UTC().Round(0)
}
rawPayload, sourceInputs := buildMonitoringRawPayload(req)
rawPayload, sourceInputs := buildMonitoringRawPayload(task.AIPlatformID, req)
tx, err := s.monitoringPool.Begin(ctx)
if err != nil {
@@ -2280,7 +2282,7 @@ func (s *MonitoringCallbackService) PatchTaskResultQueueMeta(taskID int64, recei
s.tryPatchTaskResultQueueMeta(taskID, receivedAt, patch)
}
func buildMonitoringRawPayload(req MonitoringTaskResultRequest) ([]byte, []MonitoringSourceItem) {
func buildMonitoringRawPayload(platformID string, req MonitoringTaskResultRequest) ([]byte, []MonitoringSourceItem) {
payload := make(map[string]interface{}, len(req.RawResponseJSON)+4)
for key, value := range req.RawResponseJSON {
payload[key] = value
@@ -2299,16 +2301,7 @@ func buildMonitoringRawPayload(req MonitoringTaskResultRequest) ([]byte, []Monit
payload["status"] = strings.TrimSpace(req.Status)
}
sourceInputs := append([]MonitoringSourceItem{}, req.Citations...)
sourceInputs = append(sourceInputs, req.SearchResults...)
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["citations"])...)
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["references"])...)
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["sources"])...)
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["thought_references"])...)
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["thinking_references"])...)
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["search_results"])...)
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["searchResults"])...)
sourceInputs := buildMonitoringCitationSourceInputs(platformID, req, payload)
if len(payload) == 0 {
return nil, sourceInputs
}
@@ -2320,6 +2313,34 @@ func buildMonitoringRawPayload(req MonitoringTaskResultRequest) ([]byte, []Monit
return data, sourceInputs
}
func buildMonitoringCitationSourceInputs(platformID string, req MonitoringTaskResultRequest, payload map[string]interface{}) []MonitoringSourceItem {
includeSearchResults := !monitoringPlatformUsesDedicatedCitationPanel(platformID)
sourceInputs := append([]MonitoringSourceItem{}, req.Citations...)
if includeSearchResults {
sourceInputs = append(sourceInputs, req.SearchResults...)
}
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["citations"])...)
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["references"])...)
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["sources"])...)
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["thought_references"])...)
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["thinking_references"])...)
if includeSearchResults {
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["search_results"])...)
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["searchResults"])...)
}
return sourceInputs
}
func monitoringPlatformUsesDedicatedCitationPanel(platformID string) bool {
switch normalizeMonitoringPlatformID(platformID) {
case "kimi":
return true
default:
return false
}
}
func newMonitoringTaskResultEnvelope(task *monitoringCollectTask, installationID string, receivedAt time.Time, req MonitoringTaskResultRequest) monitoringTaskResultEnvelope {
return monitoringTaskResultEnvelope{
Version: "v1",
@@ -0,0 +1,102 @@
package app
import (
"encoding/json"
"testing"
)
func TestBuildMonitoringRawPayloadKimiExcludesSearchResultsFromCitationInputs(t *testing.T) {
citationTitle := "真实引用文章"
searchTitle := "搜索网页结果"
req := MonitoringTaskResultRequest{
Status: "succeeded",
Citations: []MonitoringSourceItem{
{
URL: "https://example.com/citation",
Title: &citationTitle,
},
},
SearchResults: []MonitoringSourceItem{
{
URL: "https://example.com/search",
Title: &searchTitle,
},
},
}
rawPayload, sourceInputs := buildMonitoringRawPayload("kimi", req)
if len(sourceInputs) != 1 {
t.Fatalf("buildMonitoringRawPayload() sourceInputs len = %d, want 1", len(sourceInputs))
}
for _, item := range sourceInputs {
if item.URL != "https://example.com/citation" {
t.Fatalf("buildMonitoringRawPayload() source url = %q, want citation url", item.URL)
}
}
var payload map[string]interface{}
if err := json.Unmarshal(rawPayload, &payload); err != nil {
t.Fatalf("json.Unmarshal(rawPayload) error = %v", err)
}
if got := len(extractMonitoringSourceItems(payload["search_results"])); got != 1 {
t.Fatalf("stored search_results len = %d, want 1", got)
}
}
func TestBuildMonitoringRawPayloadQwenIncludesSearchResultsInCitationInputs(t *testing.T) {
searchTitle := "搜索网页结果"
req := MonitoringTaskResultRequest{
Status: "succeeded",
SearchResults: []MonitoringSourceItem{
{
URL: "https://example.com/search",
Title: &searchTitle,
},
},
}
_, sourceInputs := buildMonitoringRawPayload("qwen", req)
if len(sourceInputs) != 1 {
t.Fatalf("buildMonitoringRawPayload() sourceInputs len = %d, want 1", len(sourceInputs))
}
for _, item := range sourceInputs {
if item.URL != "https://example.com/search" {
t.Fatalf("buildMonitoringRawPayload() source url = %q, want search url", item.URL)
}
}
}
func TestExtractMonitoringInlineCitationsFromRawResponseKimi(t *testing.T) {
raw := []byte(`{
"inline_citation_candidates": [
{
"url": "https://example.com/a#ref",
"title": "文章 A",
"site_name": "示例站点"
},
{
"url": "https://example.com/a#other",
"title": "文章 A",
"site_name": "示例站点"
},
{
"url": "https://example.com/b",
"title": "文章 B",
"site_name": "另一个站点"
}
]
}`)
items := extractMonitoringInlineCitationsFromRawResponse(raw, "kimi")
if len(items) != 2 {
t.Fatalf("extractMonitoringInlineCitationsFromRawResponse() len = %d, want 2", len(items))
}
if items[0].URL != "https://example.com/a" {
t.Fatalf("extractMonitoringInlineCitationsFromRawResponse() first url = %q, want https://example.com/a", items[0].URL)
}
if items[1].URL != "https://example.com/b" {
t.Fatalf("extractMonitoringInlineCitationsFromRawResponse() second url = %q, want https://example.com/b", items[1].URL)
}
}
@@ -55,7 +55,7 @@ func (s *MonitoringCallbackService) rebuildBrandDailyProjection(ctx context.Cont
desiredSampleCount := enabledQuestionCount * int64(len(platforms))
coverageRate := divideAsPointer(brandAgg.ActualSampleCount, brandAgg.PlannedSampleCount)
mentionRate := averageMonitoringPlatformMentionRate(platformAggs)
mentionRate := divideAsPointer(brandAgg.MentionedCount, brandAgg.ActualSampleCount)
top1MentionRate := divideAsPointer(brandAgg.Top1MentionedCount, brandAgg.ActualSampleCount)
firstRecommendRate := divideAsPointer(brandAgg.FirstRecommendedCount, brandAgg.ActualSampleCount)
positiveMentionRate := divideAsPointer(brandAgg.PositiveMentionedCount, brandAgg.ActualSampleCount)
@@ -151,7 +151,7 @@ func loadMonitoringProjectionPlatforms(ctx context.Context, businessPool *pgxpoo
if len(enabledJSON) > 0 {
var configured []string
if jsonErr := json.Unmarshal(enabledJSON, &configured); jsonErr == nil && len(configured) > 0 {
enabled = configured
enabled = monitoringPlatformIDs(defaultMonitoringPlatforms)
}
}
@@ -372,29 +372,6 @@ func (s *MonitoringCallbackService) loadMonitoringPlatformDailyAggregates(ctx co
return items, nil
}
func averageMonitoringPlatformMentionRate(items []monitoringPlatformDailyAggregate) *float64 {
if len(items) == 0 {
return nil
}
total := 0.0
count := 0
for _, item := range items {
rate := divideAsPointer(item.MentionedCount, item.ActualSampleCount)
if rate == nil {
continue
}
total += *rate
count++
}
if count == 0 {
return nil
}
average := total / float64(count)
return &average
}
func (s *MonitoringCallbackService) replaceMonitoringPlatformDailyProjection(ctx context.Context, tx pgx.Tx, tenantID, brandID int64, businessDate time.Time, items []monitoringPlatformDailyAggregate) error {
dateText := monitoringBusinessDateText(businessDate)
File diff suppressed because it is too large Load Diff
@@ -93,3 +93,68 @@ func TestFilterMonitoringPlatformsFallsBackToDisplayName(t *testing.T) {
{ID: "doubao", Name: "豆包"},
}, filtered)
}
func TestNormalizeMonitoringPlatformIDCanonicalizesLatestIDs(t *testing.T) {
assert.Equal(t, "qwen", normalizeMonitoringPlatformID(" QWEN "))
assert.Equal(t, "wenxin", normalizeMonitoringPlatformID("WenXin"))
assert.Equal(t, "yuanbao", normalizeMonitoringPlatformID("yuanbao"))
}
func TestFilterMonitoringPlatformsMatchesCanonicalPlatform(t *testing.T) {
platforms := []monitoringPlatformMetadata{
{ID: "qwen", Name: "通义千问"},
}
selected := "qwen"
filtered := filterMonitoringPlatforms(platforms, &selected)
assert.Equal(t, []monitoringPlatformMetadata{
{ID: "qwen", Name: "通义千问"},
}, filtered)
}
func TestIntersectMonitoringPlatformsAcceptsCanonicalIDs(t *testing.T) {
enabled := []monitoringPlatformMetadata{
{ID: "qwen", Name: "通义千问"},
{ID: "wenxin", Name: "文心一言"},
}
filtered, err := intersectMonitoringPlatforms(enabled, []string{"qwen", "wenxin"})
require.NoError(t, err)
assert.Equal(t, []monitoringPlatformMetadata{
{ID: "qwen", Name: "通义千问"},
{ID: "wenxin", Name: "文心一言"},
}, filtered)
}
func TestReconcileEnabledMonitoringPlatformsBackfillsSupportedCatalog(t *testing.T) {
assert.Equal(t, []string{"yuanbao", "kimi", "wenxin", "deepseek", "doubao", "qwen"}, reconcileEnabledMonitoringPlatforms([]string{"deepseek", "qwen", "doubao"}))
}
func TestApplyDerivedRatesToOverviewSnapshotUsesAllSamplesForMentionRate(t *testing.T) {
day := time.Date(2026, 4, 22, 0, 0, 0, 0, monitoringBusinessLocation())
snapshot := monitoringOverviewSnapshot{Date: day}
derived := newMonitoringDerivedMetrics()
derived.ByDate["2026-04-22"] = monitoringDerivedRateStats{
Total: 4,
MentionedCount: 1,
Top1Count: 1,
FirstRecommend: 1,
PositiveMentions: 1,
}
derived.ByDatePlatform["2026-04-22"] = map[string]monitoringDerivedRateStats{
"qwen": {Total: 1, MentionedCount: 1},
"deepseek": {Total: 3, MentionedCount: 0},
}
applyDerivedRatesToOverviewSnapshot(&snapshot, derived)
require.True(t, snapshot.MentionRate.Valid)
require.True(t, snapshot.Top1MentionRate.Valid)
require.True(t, snapshot.FirstRecommendRate.Valid)
require.True(t, snapshot.PositiveMentionRate.Valid)
assert.InDelta(t, 0.25, snapshot.MentionRate.Float64, 0.0001)
assert.InDelta(t, 0.25, snapshot.Top1MentionRate.Float64, 0.0001)
assert.InDelta(t, 0.25, snapshot.FirstRecommendRate.Float64, 0.0001)
assert.InDelta(t, 0.25, snapshot.PositiveMentionRate.Float64, 0.0001)
}
@@ -0,0 +1,196 @@
package app
import (
"strings"
"unicode"
"unicode/utf8"
)
var monitoringWindows1252ReverseMap = map[rune]byte{
0x20AC: 0x80,
0x201A: 0x82,
0x0192: 0x83,
0x201E: 0x84,
0x2026: 0x85,
0x2020: 0x86,
0x2021: 0x87,
0x02C6: 0x88,
0x2030: 0x89,
0x0160: 0x8A,
0x2039: 0x8B,
0x0152: 0x8C,
0x017D: 0x8E,
0x2018: 0x91,
0x2019: 0x92,
0x201C: 0x93,
0x201D: 0x94,
0x2022: 0x95,
0x2013: 0x96,
0x2014: 0x97,
0x02DC: 0x98,
0x2122: 0x99,
0x0161: 0x9A,
0x203A: 0x9B,
0x0153: 0x9C,
0x017E: 0x9E,
0x0178: 0x9F,
}
func repairMonitoringMojibake(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" || !looksLikeMonitoringMojibake(trimmed) {
return value
}
repaired, ok := decodeMonitoringLatin1AsUTF8(trimmed)
if !ok || strings.ContainsRune(repaired, utf8.RuneError) || !monitoringRepairLooksBetter(trimmed, repaired) {
return value
}
return repaired
}
func normalizeMonitoringOptionalString(value *string) *string {
if value == nil {
return nil
}
repaired := repairMonitoringMojibake(*value)
return &repaired
}
func looksLikeMonitoringMojibake(value string) bool {
if value == "" {
return false
}
if strings.Contains(value, "Ã") || strings.Contains(value, "â€") || strings.Contains(value, "ï¼") || strings.Contains(value, "ã€") {
return true
}
hanCount := monitoringHanRuneCount(value)
suspiciousCount := monitoringSuspiciousRuneCount(value)
if hanCount > 0 {
return suspiciousCount >= 8 && suspiciousCount > hanCount*3
}
return suspiciousCount >= 4
}
func decodeMonitoringLatin1AsUTF8(value string) (string, bool) {
buf := make([]byte, 0, len(value))
for _, r := range value {
if mapped, ok := monitoringWindows1252ReverseMap[r]; ok {
buf = append(buf, mapped)
continue
}
if r > 255 {
return "", false
}
buf = append(buf, byte(r))
}
if !utf8.Valid(buf) {
return "", false
}
return string(buf), true
}
func monitoringRepairLooksBetter(original, repaired string) bool {
if strings.TrimSpace(repaired) == "" {
return false
}
originalHan := monitoringHanRuneCount(original)
repairedHan := monitoringHanRuneCount(repaired)
originalSuspicious := monitoringSuspiciousRuneCount(original)
repairedSuspicious := monitoringSuspiciousRuneCount(repaired)
if repairedHan > originalHan && repairedHan >= 2 {
return true
}
return repairedHan > 0 && repairedSuspicious*2 < originalSuspicious
}
func monitoringHanRuneCount(value string) int {
count := 0
for _, r := range value {
if unicode.Is(unicode.Han, r) {
count++
}
}
return count
}
func monitoringSuspiciousRuneCount(value string) int {
count := 0
for _, r := range value {
if (r >= 0x00C0 && r <= 0x017F) || (r >= 0x2000 && r <= 0x20FF) {
count++
}
}
return count
}
func normalizeMonitoringSourceItemValue(item MonitoringSourceItem) MonitoringSourceItem {
item.Title = normalizeMonitoringOptionalString(item.Title)
item.SiteName = normalizeMonitoringOptionalString(item.SiteName)
item.ResolutionStatus = normalizeMonitoringOptionalString(item.ResolutionStatus)
item.ResolutionConfidence = normalizeMonitoringOptionalString(item.ResolutionConfidence)
return item
}
func normalizeMonitoringTaskResultRequestValue(req MonitoringTaskResultRequest) MonitoringTaskResultRequest {
if req.Answer != nil {
repaired := repairMonitoringMojibake(*req.Answer)
req.Answer = &repaired
}
if req.ErrorMessage != nil {
repaired := repairMonitoringMojibake(*req.ErrorMessage)
req.ErrorMessage = &repaired
}
if len(req.Citations) > 0 {
next := make([]MonitoringSourceItem, 0, len(req.Citations))
for _, item := range req.Citations {
next = append(next, normalizeMonitoringSourceItemValue(item))
}
req.Citations = next
}
if len(req.SearchResults) > 0 {
next := make([]MonitoringSourceItem, 0, len(req.SearchResults))
for _, item := range req.SearchResults {
next = append(next, normalizeMonitoringSourceItemValue(item))
}
req.SearchResults = next
}
if len(req.RawResponseJSON) > 0 {
next := make(map[string]interface{}, len(req.RawResponseJSON))
for key, value := range req.RawResponseJSON {
next[key] = normalizeMonitoringJSONValue(value)
}
req.RawResponseJSON = next
}
return req
}
func normalizeMonitoringJSONValue(value interface{}) interface{} {
switch typed := value.(type) {
case string:
return repairMonitoringMojibake(typed)
case []interface{}:
next := make([]interface{}, len(typed))
for index, item := range typed {
next[index] = normalizeMonitoringJSONValue(item)
}
return next
case map[string]interface{}:
next := make(map[string]interface{}, len(typed))
for key, item := range typed {
next[key] = normalizeMonitoringJSONValue(item)
}
return next
default:
return value
}
}
@@ -0,0 +1,21 @@
package app
import "testing"
func TestRepairMonitoringMojibake(t *testing.T) {
input := "用户询问合肥全屋定制的性价比最高的品牌"
got := repairMonitoringMojibake(input)
want := "用户询问合肥全屋定制的性价比最高的品牌"
if got != want {
t.Fatalf("repairMonitoringMojibake() = %q, want %q", got, want)
}
}
func TestRepairMonitoringMojibakeLeavesNormalChineseUntouched(t *testing.T) {
input := "用户询问合肥全屋定制的性价比最高的品牌"
got := repairMonitoringMojibake(input)
if got != input {
t.Fatalf("repairMonitoringMojibake() changed valid text: got %q, want %q", got, input)
}
}