feat(questions): make brand questions the primary monitoring & template axis
Deployment Config CI / Deployment Config (push) Successful in 29s
Frontend CI / Frontend (push) Successful in 4m4s
Backend CI / Backend (push) Failing after 7m12s

- add /questions/combination-fill endpoint with AI-driven, IP-region-aware matrix fill
- extract ip2region resolver from ops/app into shared/ipregion for cross-service use
- thread question_id filter through dashboard composite, citation summary, and collect-now
- switch template wizard from keyword inputs to brand-question selection (primary + supplemental)
- pass brand_question and supplemental_questions through assist/title/outline prompts
- add AiWaitingModal + 45s client timeout and request_timeout error mapping for long AI flows

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 15:59:39 +08:00
parent 37b0b32327
commit 1eae6fb6d4
36 changed files with 2119 additions and 412 deletions
@@ -356,6 +356,7 @@ func (s *MonitoringService) DashboardComposite(
ctx context.Context,
brandID int64,
keywordID *int64,
questionID *int64,
days int,
businessDate string,
aiPlatformID *string,
@@ -388,6 +389,10 @@ func (s *MonitoringService) DashboardComposite(
if err != nil {
return nil, err
}
configuredQuestions, err = filterConfiguredQuestionsByQuestionID(configuredQuestions, questionID)
if err != nil {
return nil, err
}
platforms := defaultMonitoringPlatformMetadata()
selectedPlatformID := normalizedOptionalMonitoringPlatformPointer(aiPlatformID)
@@ -396,11 +401,12 @@ func (s *MonitoringService) DashboardComposite(
if err != nil {
return nil, err
}
derivedMetrics, err := s.loadDerivedParseMetrics(ctx, actor.TenantID, brand.ID, brand.Name, startDate, endDate, selectedPlatformID)
questionIDs := configuredQuestionIDs(configuredQuestions)
derivedMetrics, err := s.loadDerivedParseMetrics(ctx, actor.TenantID, brand.ID, questionIDs, brand.Name, startDate, endDate, selectedPlatformID)
if err != nil {
return nil, err
}
overview, _, err := s.loadOverview(ctx, actor.TenantID, brand.ID, startDate, endDate, quota.CollectionMode, selectedPlatformID, derivedMetrics)
overview, _, err := s.loadOverview(ctx, actor.TenantID, brand.ID, questionIDs, startDate, endDate, quota.CollectionMode, selectedPlatformID, derivedMetrics)
if err != nil {
return nil, err
}
@@ -410,7 +416,7 @@ func (s *MonitoringService) DashboardComposite(
return nil, err
}
platformBreakdown, err := s.loadPlatformBreakdown(ctx, actor.TenantID, brand.ID, endDate, quota.CollectionMode, filteredPlatforms, accessStates, derivedMetrics)
platformBreakdown, err := s.loadPlatformBreakdown(ctx, actor.TenantID, brand.ID, questionIDs, endDate, quota.CollectionMode, filteredPlatforms, accessStates, derivedMetrics)
if err != nil {
return nil, err
}
@@ -440,6 +446,7 @@ func (s *MonitoringService) CitationSummary(
days int,
brandID int64,
keywordID *int64,
questionID *int64,
businessDate string,
aiPlatformID *string,
) (*MonitoringCitationSummaryResponse, error) {
@@ -461,6 +468,10 @@ func (s *MonitoringService) CitationSummary(
if err != nil {
return nil, err
}
configuredQuestions, err = filterConfiguredQuestionsByQuestionID(configuredQuestions, questionID)
if err != nil {
return nil, err
}
questionIDs = configuredQuestionIDs(configuredQuestions)
}
@@ -641,6 +652,7 @@ func (s *MonitoringService) CollectNow(
ctx context.Context,
brandID int64,
keywordID *int64,
questionID *int64,
options MonitoringCollectNowOptions,
) (*MonitoringCollectNowResponse, error) {
actor := auth.MustActor(ctx)
@@ -666,13 +678,24 @@ func (s *MonitoringService) CollectNow(
if err != nil {
return nil, err
}
configuredQuestions, err = filterConfiguredQuestionsByQuestionID(configuredQuestions, questionID)
if err != nil {
return nil, err
}
limitApplied := false
if len(configuredQuestions) > monitoringCollectNowQuestionLimit {
if questionID == nil && len(configuredQuestions) > monitoringCollectNowQuestionLimit {
configuredQuestions = configuredQuestions[:monitoringCollectNowQuestionLimit]
limitApplied = true
}
if len(configuredQuestions) == 0 {
message := "当前品牌下暂无品牌库问题,未创建采集任务"
if keywordID != nil {
message = "当前问题集下暂无品牌库问题,未创建采集任务"
}
if questionID != nil {
message = "当前问题不可用,未创建采集任务"
}
return &MonitoringCollectNowResponse{
CollectionMode: quota.CollectionMode,
RefreshedTaskCount: 0,
@@ -680,7 +703,7 @@ func (s *MonitoringService) CollectNow(
LeasedTaskCount: 0,
CompletedTaskCount: 0,
HasEffectiveSnapshot: true,
Message: "当前关键词下暂无品牌库问题,未创建采集任务",
Message: message,
}, nil
}
@@ -800,7 +823,7 @@ func (s *MonitoringService) CollectNow(
return nil, err
}
if err := s.syncMonitoringQuestionSnapshots(ctx, tx, actor.TenantID, brand.ID, configuredQuestions, keywordID == nil); err != nil {
if err := s.syncMonitoringQuestionSnapshots(ctx, tx, actor.TenantID, brand.ID, configuredQuestions, keywordID == nil && questionID == nil); err != nil {
return nil, err
}
@@ -1487,6 +1510,21 @@ func configuredQuestionIDs(questions []monitoringConfiguredQuestion) []int64 {
return ids
}
func filterConfiguredQuestionsByQuestionID(questions []monitoringConfiguredQuestion, questionID *int64) ([]monitoringConfiguredQuestion, error) {
if questionID == nil {
return questions, nil
}
if *questionID <= 0 {
return nil, response.ErrBadRequest(40031, "invalid_question_id", "question_id must be a positive number")
}
for _, question := range questions {
if question.ID == *questionID {
return []monitoringConfiguredQuestion{question}, nil
}
}
return nil, response.ErrBadRequest(40041, "invalid_question", "question does not belong to the selected brand or question set")
}
func (s *MonitoringService) syncMonitoringQuestionSnapshots(ctx context.Context, tx pgx.Tx, tenantID, brandID int64, questions []monitoringConfiguredQuestion, pruneMissing bool) error {
if pruneMissing {
if len(questions) == 0 {
@@ -2195,11 +2233,15 @@ func (stats monitoringDerivedRateStats) positiveMentionRate() *float64 {
func (s *MonitoringService) loadDerivedParseMetrics(
ctx context.Context,
tenantID, brandID int64,
questionIDs []int64,
brandName string,
startDate, endDate time.Time,
aiPlatformID *string,
) (monitoringDerivedMetrics, error) {
metrics := newMonitoringDerivedMetrics()
if questionIDs != nil && len(questionIDs) == 0 {
return metrics, nil
}
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
rows, err := s.monitoringPool.Query(ctx, `
@@ -2221,8 +2263,9 @@ func (s *MonitoringService) loadDerivedParseMetrics(
AND r.collector_type = $3
AND r.status = 'succeeded'
AND r.business_date BETWEEN $4::date AND $5::date
AND ($6::text[] IS NULL OR r.ai_platform_id = ANY($6))
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableStringArray(platformQueryIDs))
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs))
if err != nil {
return metrics, response.ErrInternal(50041, "query_failed", "failed to load monitoring derived parse metrics")
}
@@ -2318,11 +2361,15 @@ func decodeMonitoringMatchedBrandTerms(raw []byte) []string {
func (s *MonitoringService) loadOverview(
ctx context.Context,
tenantID, brandID int64,
questionIDs []int64,
startDate, endDate time.Time,
collectionMode string,
aiPlatformID *string,
derived monitoringDerivedMetrics,
) (MonitoringOverview, time.Time, error) {
if questionIDs != nil {
return s.loadOverviewFromRuns(ctx, tenantID, brandID, questionIDs, startDate, endDate, collectionMode, aiPlatformID, derived)
}
if platformID := normalizedOptionalMonitoringPlatformID(aiPlatformID); platformID != "" {
return s.loadOverviewForPlatform(ctx, tenantID, brandID, startDate, endDate, collectionMode, platformID, derived)
}
@@ -2364,6 +2411,56 @@ func (s *MonitoringService) loadOverview(
return overview, latestDate, nil
}
func (s *MonitoringService) loadOverviewFromRuns(
ctx context.Context,
tenantID, brandID int64,
questionIDs []int64,
startDate, endDate time.Time,
collectionMode string,
aiPlatformID *string,
derived monitoringDerivedMetrics,
) (MonitoringOverview, time.Time, error) {
overview := MonitoringOverview{
CollectionMode: collectionMode,
ConfidenceLevel: "low",
}
latestDate := endDate
if len(questionIDs) == 0 {
return overview, latestDate, nil
}
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
current, found, err := s.loadOverviewRunAggregateForDate(ctx, tenantID, brandID, questionIDs, endDate, platformQueryIDs)
if err != nil {
return overview, latestDate, err
}
if !found {
return overview, latestDate, nil
}
applyOverviewSnapshot(&overview, current, derived)
if current.ActualSampleCount <= 0 {
return overview, latestDate, nil
}
latestDate = current.Date
previous, foundPrevious, err := s.loadLatestOverviewRunAggregate(ctx, tenantID, brandID, questionIDs, startDate, current.Date.AddDate(0, 0, -1), platformQueryIDs)
if err != nil {
return overview, latestDate, err
}
if !foundPrevious {
return overview, latestDate, nil
}
applyDerivedRatesToOverviewSnapshot(&previous, derived)
overview.PrevSnapshotMentionRate = floatPointer(previous.MentionRate)
overview.PrevSnapshotTop1MentionRate = floatPointer(previous.Top1MentionRate)
overview.PrevSnapshotFirstRecommendRate = floatPointer(previous.FirstRecommendRate)
overview.PrevSnapshotPositiveMentionRate = floatPointer(previous.PositiveMentionRate)
return overview, latestDate, nil
}
func (s *MonitoringService) loadOverviewForPlatform(
ctx context.Context,
tenantID, brandID int64,
@@ -2434,6 +2531,140 @@ type monitoringOverviewSnapshot struct {
CitationRate sql.NullFloat64
}
func (s *MonitoringService) loadOverviewRunAggregateForDate(
ctx context.Context,
tenantID, brandID int64,
questionIDs []int64,
businessDate time.Time,
platformIDs []string,
) (monitoringOverviewSnapshot, bool, error) {
return s.loadOverviewRunAggregate(ctx, tenantID, brandID, questionIDs, businessDate, businessDate, platformIDs, false)
}
func (s *MonitoringService) loadLatestOverviewRunAggregate(
ctx context.Context,
tenantID, brandID int64,
questionIDs []int64,
startDate, endDate time.Time,
platformIDs []string,
) (monitoringOverviewSnapshot, bool, error) {
if endDate.Before(startDate) {
return monitoringOverviewSnapshot{}, false, nil
}
return s.loadOverviewRunAggregate(ctx, tenantID, brandID, questionIDs, startDate, endDate, platformIDs, true)
}
func (s *MonitoringService) loadOverviewRunAggregate(
ctx context.Context,
tenantID, brandID int64,
questionIDs []int64,
startDate, endDate time.Time,
platformIDs []string,
requireSamples bool,
) (monitoringOverviewSnapshot, bool, error) {
var item monitoringOverviewSnapshot
if len(questionIDs) == 0 {
return item, false, nil
}
rows, err := s.monitoringPool.Query(ctx, `
SELECT
r.business_date,
COUNT(*)::bigint AS actual_sample_count,
COUNT(*) FILTER (WHERE p.brand_mentioned IS TRUE)::bigint AS mentioned_count,
COUNT(*) FILTER (WHERE p.brand_mention_position = 'top1')::bigint AS top1_mentioned_count,
COUNT(*) FILTER (WHERE p.first_recommended IS TRUE)::bigint AS first_recommended_count,
COUNT(*) FILTER (WHERE p.sentiment_label = 'positive')::bigint AS positive_mentioned_count,
MAX(r.completed_at) AS last_sampled_at
FROM question_monitor_runs r
LEFT JOIN question_monitor_parse_results p
ON p.tenant_id = r.tenant_id AND p.run_id = r.id
WHERE r.tenant_id = $1
AND r.brand_id = $2
AND r.collector_type = $3
AND r.status = 'succeeded'
AND r.business_date BETWEEN $4::date AND $5::date
AND r.question_id = ANY($6)
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
GROUP BY r.business_date
ORDER BY r.business_date DESC
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), questionIDs, nullableStringArray(platformIDs))
if err != nil {
return item, false, response.ErrInternal(50041, "query_failed", "failed to load monitoring overview")
}
defer rows.Close()
for rows.Next() {
var mentionedCount int64
var top1MentionedCount int64
var firstRecommendedCount int64
var positiveMentionedCount int64
if scanErr := rows.Scan(
&item.Date,
&item.ActualSampleCount,
&mentionedCount,
&top1MentionedCount,
&firstRecommendedCount,
&positiveMentionedCount,
&item.SnapshotUpdatedAt,
); scanErr != nil {
return monitoringOverviewSnapshot{}, false, response.ErrInternal(50041, "scan_failed", "failed to parse monitoring overview")
}
if requireSamples && item.ActualSampleCount <= 0 {
continue
}
plannedSampleCount, planErr := s.loadOverviewRunPlannedCount(ctx, tenantID, brandID, questionIDs, item.Date, platformIDs)
if planErr != nil {
return monitoringOverviewSnapshot{}, false, planErr
}
item.DesiredSampleCount = plannedSampleCount
item.PlannedSampleCount = plannedSampleCount
item.CoverageRate = pointerFloat64ToNull(divideAsPointer(item.ActualSampleCount, plannedSampleCount))
item.ConfidenceLevel = deriveMonitoringConfidenceLevel(item.ActualSampleCount, plannedSampleCount, floatPointer(item.CoverageRate))
item.MentionRate = pointerFloat64ToNull(divideAsPointer(mentionedCount, item.ActualSampleCount))
item.Top1MentionRate = pointerFloat64ToNull(divideAsPointer(top1MentionedCount, item.ActualSampleCount))
item.FirstRecommendRate = pointerFloat64ToNull(divideAsPointer(firstRecommendedCount, item.ActualSampleCount))
item.PositiveMentionRate = pointerFloat64ToNull(divideAsPointer(positiveMentionedCount, item.ActualSampleCount))
return item, true, nil
}
if err := rows.Err(); err != nil {
return monitoringOverviewSnapshot{}, false, response.ErrInternal(50041, "scan_failed", "failed to iterate monitoring overview")
}
return monitoringOverviewSnapshot{}, false, nil
}
func (s *MonitoringService) loadOverviewRunPlannedCount(
ctx context.Context,
tenantID, brandID int64,
questionIDs []int64,
businessDate time.Time,
platformIDs []string,
) (int64, error) {
var plannedSampleCount int64
if err := s.monitoringPool.QueryRow(ctx, `
SELECT COUNT(*)::bigint
FROM monitoring_collect_tasks
WHERE tenant_id = $1
AND brand_id = $2
AND collector_type = $3
AND business_date = $4::date
AND question_id = ANY($5)
AND ($6::text[] IS NULL OR ai_platform_id = ANY($6))
AND NOT (status = 'skipped' AND skip_reason = ANY($7::text[]))
`, tenantID, brandID, monitoringCollectorType, businessDate.Format("2006-01-02"), questionIDs, nullableStringArray(platformIDs), monitoringProjectionExcludedSkipReasons).Scan(&plannedSampleCount); err != nil {
return 0, response.ErrInternal(50041, "query_failed", "failed to load monitoring overview")
}
if plannedSampleCount > 0 {
return plannedSampleCount, nil
}
if platformIDs != nil {
return int64(len(questionIDs) * len(platformIDs)), nil
}
return int64(len(questionIDs) * len(defaultMonitoringPlatforms)), nil
}
func (s *MonitoringService) loadOverviewSnapshotForDate(ctx context.Context, tenantID, brandID int64, collectionMode string, businessDate time.Time) (monitoringOverviewSnapshot, bool, error) {
var item monitoringOverviewSnapshot
err := s.monitoringPool.QueryRow(ctx, `
@@ -2765,7 +2996,11 @@ func (s *MonitoringService) monitoringClientBelongsToWorkspace(ctx context.Conte
return exists, nil
}
func (s *MonitoringService) loadPlatformBreakdown(ctx context.Context, tenantID, brandID int64, businessDate time.Time, collectionMode string, platforms []monitoringPlatformMetadata, accessStates map[string]monitoringAccessState, derived monitoringDerivedMetrics) ([]MonitoringPlatformDaily, error) {
func (s *MonitoringService) loadPlatformBreakdown(ctx context.Context, tenantID, brandID int64, questionIDs []int64, businessDate time.Time, collectionMode string, platforms []monitoringPlatformMetadata, accessStates map[string]monitoringAccessState, derived monitoringDerivedMetrics) ([]MonitoringPlatformDaily, error) {
if questionIDs != nil {
return s.loadPlatformBreakdownFromRuns(ctx, tenantID, brandID, questionIDs, businessDate, platforms, accessStates, derived)
}
rows, err := s.monitoringPool.Query(ctx, `
SELECT
ai_platform_id,
@@ -2854,6 +3089,102 @@ func (s *MonitoringService) loadPlatformBreakdown(ctx context.Context, tenantID,
return breakdown, nil
}
func (s *MonitoringService) loadPlatformBreakdownFromRuns(ctx context.Context, tenantID, brandID int64, questionIDs []int64, businessDate time.Time, platforms []monitoringPlatformMetadata, accessStates map[string]monitoringAccessState, derived monitoringDerivedMetrics) ([]MonitoringPlatformDaily, error) {
if len(questionIDs) == 0 {
return emptyPlatformBreakdown(platforms, accessStates), nil
}
dateKey := businessDate.Format("2006-01-02")
rows, err := s.monitoringPool.Query(ctx, `
SELECT
r.ai_platform_id,
COUNT(*)::bigint AS actual_sample_count,
COUNT(*) FILTER (WHERE p.brand_mentioned IS TRUE)::bigint AS mentioned_count,
COUNT(*) FILTER (WHERE p.brand_mention_position = 'top1')::bigint AS top1_mentioned_count,
MAX(r.completed_at) AS last_sampled_at
FROM question_monitor_runs r
LEFT JOIN question_monitor_parse_results p
ON p.tenant_id = r.tenant_id AND p.run_id = r.id
WHERE r.tenant_id = $1
AND r.brand_id = $2
AND r.collector_type = $3
AND r.business_date = $4::date
AND r.status = 'succeeded'
AND r.question_id = ANY($5)
GROUP BY r.ai_platform_id
`, tenantID, brandID, monitoringCollectorType, dateKey, questionIDs)
if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load platform breakdown")
}
defer rows.Close()
type row struct {
MentionRate sql.NullFloat64
Top1MentionRate sql.NullFloat64
ActualSampleCount int64
LastSampledAt sql.NullTime
}
items := make(map[string]row)
for rows.Next() {
var platformID string
var mentionedCount int64
var top1MentionedCount int64
var item row
if scanErr := rows.Scan(
&platformID,
&item.ActualSampleCount,
&mentionedCount,
&top1MentionedCount,
&item.LastSampledAt,
); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse platform breakdown")
}
platformID = normalizeMonitoringPlatformID(platformID)
if platformID == "" {
continue
}
item.MentionRate = pointerFloat64ToNull(divideAsPointer(mentionedCount, item.ActualSampleCount))
item.Top1MentionRate = pointerFloat64ToNull(divideAsPointer(top1MentionedCount, item.ActualSampleCount))
items[platformID] = item
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate platform breakdown")
}
breakdown := make([]MonitoringPlatformDaily, 0, len(platforms))
for _, platform := range platforms {
item := items[platform.ID]
if platformStats, ok := derived.ByDatePlatform[dateKey][platform.ID]; ok && platformStats.hasSamples() {
item.MentionRate = pointerFloat64ToNull(platformStats.mentionRate())
item.Top1MentionRate = pointerFloat64ToNull(platformStats.top1MentionRate())
}
breakdown = append(breakdown, MonitoringPlatformDaily{
AIPlatformID: platform.ID,
PlatformName: platform.Name,
MentionRate: floatPointer(item.MentionRate),
Top1MentionRate: floatPointer(item.Top1MentionRate),
PlatformSampleStatus: deriveMonitoringPlatformSampleStatus(item.ActualSampleCount, item.ActualSampleCount, accessStates[platform.ID].AccessStatus),
ActualSampleCount: item.ActualSampleCount,
LastSampledAt: formatNullTime(item.LastSampledAt),
})
}
return breakdown, nil
}
func emptyPlatformBreakdown(platforms []monitoringPlatformMetadata, accessStates map[string]monitoringAccessState) []MonitoringPlatformDaily {
breakdown := make([]MonitoringPlatformDaily, 0, len(platforms))
for _, platform := range platforms {
breakdown = append(breakdown, MonitoringPlatformDaily{
AIPlatformID: platform.ID,
PlatformName: platform.Name,
PlatformSampleStatus: derivePlatformSampleStatus("", accessStates[platform.ID]),
})
}
return breakdown
}
func (s *MonitoringService) loadHotQuestions(
ctx context.Context,
tenantID, brandID int64,