refactor(comparison): 评审修复 — 共享 gateway_chat_client、指标元表、对比区块组件化
- analysis/comparison 重复的 _gateway_chat_client 提取为共享 gateway_chat_client - Campaigns.tsx 周期对比区块抽为 PeriodComparisonSection 组件,指标格式化 收敛为单一 METRICS 元表(消除三处 metric 分支级联) - 基线下拉排除无 completed_at 的终态活动(选中必 400) - spec/issue 03 追认 delta 按好坏着色口径与 GET 生效基线合并口径
This commit is contained in:
parent
1e55a21649
commit
dd3b9a5e91
@ -1,6 +1,6 @@
|
|||||||
# 03 — 报告抽屉「周期对比」区块
|
# 03 — 报告抽屉「周期对比」区块
|
||||||
|
|
||||||
**What to build:** 报告抽屉在「智能分析」之下新增「周期对比」区块。状态行:生成中(spinner + 5s 轮询)、失败(错误原因 + 重试)、未生成(「生成对比」按钮;无自动基线时显示基线选择器——同对象历史终态活动下拉,选定后生成)。已生成依次渲染:趋势徽章(improving 绿 / stable 蓝 / regressing 红)+ 总结 → 指标变化表(整窗行 + 分场景行,delta 正绿负红、None 显示 —)→ 问题演化列表(新增红 / 持续橙 / 消解绿 Tag + 标题 + 说明 + 场景 Tag)→ 建议追踪(已落实绿 / 部分橙 / 未落实灰 / 新增蓝)。展示基线活动名与完成时间、所用分析模型名与生成时间。生成按钮仅终态活动可用。
|
**What to build:** 报告抽屉在「智能分析」之下新增「周期对比」区块。状态行:生成中(spinner + 5s 轮询)、失败(错误原因 + 重试)、未生成(「生成对比」按钮;无自动基线时显示基线选择器——同对象历史终态活动下拉,选定后生成)。已生成依次渲染:趋势徽章(improving 绿 / stable 蓝 / regressing 红)+ 总结 → 指标变化表(整窗行 + 分场景行,delta 按好坏着色——比率升为绿、时延降为绿,反之红、None 显示 —)→ 问题演化列表(新增红 / 持续橙 / 消解绿 Tag + 标题 + 说明 + 场景 Tag)→ 建议追踪(已落实绿 / 部分橙 / 未落实灰 / 新增蓝)。展示基线活动名与完成时间、所用分析模型名与生成时间。生成按钮仅终态活动可用。
|
||||||
|
|
||||||
**Blocked by:** 02(API 与结果结构)
|
**Blocked by:** 02(API 与结果结构)
|
||||||
|
|
||||||
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
- [x] `api.ts`:对比类型 + `getComparison` / `generateComparison(id, baselineId?)`
|
- [x] `api.ts`:对比类型 + `getComparison` / `generateComparison(id, baselineId?)`
|
||||||
- [x] 区块按状态渲染(生成中轮询复用 `usePolling`)
|
- [x] 区块按状态渲染(生成中轮询复用 `usePolling`)
|
||||||
- [x] 已生成渲染:趋势徽章 + 总结 / 指标 delta 表(正绿负红)/ 问题演化 Tag / 建议追踪状态
|
- [x] 已生成渲染:趋势徽章 + 总结 / 指标 delta 表(按好坏着色)/ 问题演化 Tag / 建议追踪状态
|
||||||
- [x] 无自动基线时的基线选择器(同对象终态活动下拉)
|
- [x] 无自动基线时的基线选择器(同对象终态活动下拉)
|
||||||
- [x] 基线活动名 + 完成时间 + 模型名 + 生成时间展示;按钮终态门控
|
- [x] 基线活动名 + 完成时间 + 模型名 + 生成时间展示;按钮终态门控
|
||||||
- [x] `npx tsc --noEmit` + `npm run build` 通过;浏览器实操验证
|
- [x] `npx tsc --noEmit` + `npm run build` 通过;浏览器实操验证
|
||||||
|
|||||||
@ -57,14 +57,14 @@
|
|||||||
|
|
||||||
### API
|
### API
|
||||||
|
|
||||||
- `GET /api/campaigns/{id}/comparison`:无行 → 空态 `{"status": "none"}`;有行 → 机械 diff(现算)+ 叙述行合并返回;附 `baseline_campaign_id` 与自动配对结果,前端据此渲染或引导选基线
|
- `GET /api/campaigns/{id}/comparison`:无行 → 空态 `{"status": "none"}`;有行 → 机械 diff(现算)+ 叙述行合并返回;附 `baseline_campaign_id` 与自动配对结果,前端据此渲染或引导选基线。`metric_diff` 跟随生效基线:有对比行按行内基线算,否则按自动基线算
|
||||||
- `POST /api/campaigns/{id}/comparison`:body 可选 `{"baseline_campaign_id": "..."}`;非终态活动 400;当前活动无 completed 分析 400(先生成分析);无模型 400 引导;成功 → 后台任务 + `{"status": "generating"}`
|
- `POST /api/campaigns/{id}/comparison`:body 可选 `{"baseline_campaign_id": "..."}`;非终态活动 400;当前活动无 completed 分析 400(先生成分析);无模型 400 引导;成功 → 后台任务 + `{"status": "generating"}`
|
||||||
- 两期都有 completed 分析是生成前提(基线无分析 → 400 提示先生成基线分析)
|
- 两期都有 completed 分析是生成前提(基线无分析 → 400 提示先生成基线分析)
|
||||||
|
|
||||||
### 前端(报告抽屉「周期对比」区块)
|
### 前端(报告抽屉「周期对比」区块)
|
||||||
|
|
||||||
- 位置:「智能分析」区块之下,复用区块状态行范式(生成中 5s 轮询 / 失败重试 / 未生成按钮)
|
- 位置:「智能分析」区块之下,复用区块状态行范式(生成中 5s 轮询 / 失败重试 / 未生成按钮)
|
||||||
- 已生成:趋势徽章 + 总结 → 指标变化表(整窗 + 分场景 delta,正绿负红)→ 问题演化(新增红/持续橙/消解绿 Tag)→ 建议追踪(已落实绿/部分橙/未落实灰/新增蓝)
|
- 已生成:趋势徽章 + 总结 → 指标变化表(整窗 + 分场景 delta,按好坏着色——比率升为绿、时延降为绿,反之红)→ 问题演化(新增红/持续橙/消解绿 Tag)→ 建议追踪(已落实绿/部分橙/未落实灰/新增蓝)
|
||||||
- 无自动基线:基线选择器(同对象历史终态活动下拉)+ 生成按钮
|
- 无自动基线:基线选择器(同对象历史终态活动下拉)+ 生成按钮
|
||||||
- 展示基线活动名与完成时间、所用分析模型名与生成时间
|
- 展示基线活动名与完成时间、所用分析模型名与生成时间
|
||||||
|
|
||||||
|
|||||||
@ -262,7 +262,8 @@ def _maybe_enqueue_period_comparison(campaign: Campaign, session: Session) -> No
|
|||||||
_logger.warning("活动 %s 自动周期对比跳过: %s", campaign.id, exc)
|
_logger.warning("活动 %s 自动周期对比跳过: %s", campaign.id, exc)
|
||||||
|
|
||||||
|
|
||||||
def _gateway_chat_client(runtime: ModelRuntimeConfig) -> ChatClient:
|
def gateway_chat_client(runtime: ModelRuntimeConfig) -> ChatClient:
|
||||||
|
"""Shared ChatClient factory for analysis/comparison background executors."""
|
||||||
gateway = ModelGateway(timeout=180.0)
|
gateway = ModelGateway(timeout=180.0)
|
||||||
|
|
||||||
async def _chat(messages: list[dict[str, str]]) -> str:
|
async def _chat(messages: list[dict[str, str]]) -> str:
|
||||||
@ -299,7 +300,7 @@ async def execute_campaign_analysis(
|
|||||||
model_config_id=runtime.id, triggered_by=triggered_by,
|
model_config_id=runtime.id, triggered_by=triggered_by,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
client = chat_client or _gateway_chat_client(runtime)
|
client = chat_client or gateway_chat_client(runtime)
|
||||||
runs = RunRepository(session).list_by_campaign(campaign_id)
|
runs = RunRepository(session).list_by_campaign(campaign_id)
|
||||||
scenario_names = {s.id: s.name for s in ScenarioRepository(session).list_all()}
|
scenario_names = {s.id: s.name for s in ScenarioRepository(session).list_all()}
|
||||||
report = generate_campaign_report(campaign, runs, scenario_names=scenario_names)
|
report = generate_campaign_report(campaign, runs, scenario_names=scenario_names)
|
||||||
|
|||||||
@ -14,11 +14,9 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from agenteval.evaluation.analysis import ChatClient, resolve_analysis_model
|
from agenteval.evaluation.analysis import ChatClient, gateway_chat_client, resolve_analysis_model
|
||||||
from agenteval.evaluation.report import generate_campaign_report
|
from agenteval.evaluation.report import generate_campaign_report
|
||||||
from agenteval.model_gateway import ModelGateway
|
|
||||||
from agenteval.models import Campaign, EvalRun
|
from agenteval.models import Campaign, EvalRun
|
||||||
from agenteval.services.model_configs import ModelRuntimeConfig
|
|
||||||
from agenteval.storage.db import get_session, iso_utc, utc_now
|
from agenteval.storage.db import get_session, iso_utc, utc_now
|
||||||
from agenteval.storage.repository import (
|
from agenteval.storage.repository import (
|
||||||
CampaignAnalysisRepository,
|
CampaignAnalysisRepository,
|
||||||
@ -277,15 +275,6 @@ async def narrate_period_comparison(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _gateway_chat_client(runtime: ModelRuntimeConfig) -> ChatClient:
|
|
||||||
gateway = ModelGateway(timeout=180.0)
|
|
||||||
|
|
||||||
async def _chat(messages: list[dict[str, str]]) -> str:
|
|
||||||
return await gateway.chat(runtime, messages, temperature=0.2)
|
|
||||||
|
|
||||||
return _chat
|
|
||||||
|
|
||||||
|
|
||||||
async def execute_campaign_comparison(
|
async def execute_campaign_comparison(
|
||||||
campaign_id: str,
|
campaign_id: str,
|
||||||
*,
|
*,
|
||||||
@ -378,7 +367,7 @@ async def execute_campaign_comparison(
|
|||||||
current_analysis=current_analysis.get_result(),
|
current_analysis=current_analysis.get_result(),
|
||||||
metric_diff=diff,
|
metric_diff=diff,
|
||||||
valid_scenario_ids=valid_scenario_ids,
|
valid_scenario_ids=valid_scenario_ids,
|
||||||
chat_client=chat_client or _gateway_chat_client(runtime),
|
chat_client=chat_client or gateway_chat_client(runtime),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.warning("活动 %s 周期对比失败: %s", campaign_id, exc)
|
_logger.warning("活动 %s 周期对比失败: %s", campaign_id, exc)
|
||||||
|
|||||||
286
frontend/web/src/components/PeriodComparisonSection.tsx
Normal file
286
frontend/web/src/components/PeriodComparisonSection.tsx
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
import { useState, type CSSProperties } from 'react'
|
||||||
|
import { Alert, Button, Select, Space, Spin, Table, Tag, Tooltip } from 'antd'
|
||||||
|
import {
|
||||||
|
type CampaignComparison, type CampaignListItem, type MetricDeltaPair,
|
||||||
|
type MetricDiff, type ModelConfig,
|
||||||
|
} from '../api'
|
||||||
|
import { shortDateTime } from '../utils/date'
|
||||||
|
import { colors } from '../tokens'
|
||||||
|
|
||||||
|
const TREND_META: Record<string, { label: string; color: string }> = {
|
||||||
|
improving: { label: '改善', color: 'green' },
|
||||||
|
stable: { label: '平稳', color: 'blue' },
|
||||||
|
regressing: { label: '退化', color: 'red' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const EVOLUTION_META: Record<string, { label: string; color: string }> = {
|
||||||
|
new: { label: '新增', color: 'red' },
|
||||||
|
persisting: { label: '持续', color: 'orange' },
|
||||||
|
resolved: { label: '消解', color: 'green' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRACKING_META: Record<string, { label: string; color: string }> = {
|
||||||
|
addressed: { label: '已落实', color: 'green' },
|
||||||
|
partial: { label: '部分落实', color: 'orange' },
|
||||||
|
unaddressed: { label: '未落实', color: 'default' },
|
||||||
|
new: { label: '新增', color: 'blue' },
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetricKey = 'pass_rate' | 'availability' | 'avg_latency_ms'
|
||||||
|
|
||||||
|
const fmtPct = (v: number) => `${(v * 100).toFixed(1)}%`
|
||||||
|
const signed = (d: number, body: string) => `${d > 0 ? '+' : ''}${body}`
|
||||||
|
|
||||||
|
/** Single metric meta table: label, formatting and which direction is good. */
|
||||||
|
const METRICS: {
|
||||||
|
key: MetricKey
|
||||||
|
label: string
|
||||||
|
goodDirection: 'up' | 'down'
|
||||||
|
fmt: (v: number) => string
|
||||||
|
fmtDelta: (d: number) => string
|
||||||
|
}[] = [
|
||||||
|
{ key: 'pass_rate', label: '通过率', goodDirection: 'up', fmt: fmtPct, fmtDelta: (d) => signed(d, `${(d * 100).toFixed(1)}pp`) },
|
||||||
|
{ key: 'availability', label: '可用性', goodDirection: 'up', fmt: fmtPct, fmtDelta: (d) => signed(d, `${(d * 100).toFixed(1)}pp`) },
|
||||||
|
{ key: 'avg_latency_ms', label: '平均时延', goodDirection: 'down', fmt: (v) => `${Math.round(v)}ms`, fmtDelta: (d) => signed(d, `${Math.round(d * 10) / 10}ms`) },
|
||||||
|
]
|
||||||
|
|
||||||
|
const boxStyle: CSSProperties = {
|
||||||
|
border: `1px solid ${colors.border}`, borderRadius: 8,
|
||||||
|
padding: '12px 14px', marginBottom: 16,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
comparison: CampaignComparison | null
|
||||||
|
/** 活动已终态(按钮门控之一) */
|
||||||
|
terminal: boolean
|
||||||
|
/** 当前活动智能分析已 completed(对比前提) */
|
||||||
|
analysisCompleted: boolean
|
||||||
|
targetId: string
|
||||||
|
currentCampaignId: string
|
||||||
|
campaigns: CampaignListItem[]
|
||||||
|
scenarioNames: Record<string, string>
|
||||||
|
modelConfigs: ModelConfig[]
|
||||||
|
onGenerate: (baselineCampaignId?: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PeriodComparisonSection({
|
||||||
|
comparison, terminal, analysisCompleted, targetId, currentCampaignId,
|
||||||
|
campaigns, scenarioNames, modelConfigs, onGenerate,
|
||||||
|
}: Props) {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [baselineChoice, setBaselineChoice] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
|
const generate = async (baselineCampaignId?: string) => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await onGenerate(baselineCampaignId)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baselineCandidates = campaigns
|
||||||
|
.filter((c) => c.target_id === targetId
|
||||||
|
&& c.id !== currentCampaignId
|
||||||
|
&& c.completed_at != null
|
||||||
|
&& !(c.status === 'planned' || c.status === 'running'))
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (b.completed_at ?? '').localeCompare(a.completed_at ?? ''))
|
||||||
|
|
||||||
|
const button = (label: string, baselineCampaignId?: string, requireBaseline = false) => (
|
||||||
|
<Tooltip title={
|
||||||
|
!terminal ? '活动完成后可生成'
|
||||||
|
: !analysisCompleted ? '请先生成智能分析'
|
||||||
|
: requireBaseline && !baselineCampaignId ? '请先选择基线活动'
|
||||||
|
: undefined
|
||||||
|
}>
|
||||||
|
<Button
|
||||||
|
size="small" type="primary" ghost
|
||||||
|
loading={busy}
|
||||||
|
disabled={!terminal || !analysisCompleted || (requireBaseline && !baselineCampaignId)}
|
||||||
|
onClick={() => void generate(baselineCampaignId)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
|
||||||
|
const metricDiffTable = (diff: MetricDiff) => {
|
||||||
|
const rows = [
|
||||||
|
{ key: 'overall', name: '整窗(总体)', ...diff.overall },
|
||||||
|
...diff.scenarios.map((s) => ({
|
||||||
|
key: s.scenario_id,
|
||||||
|
name: s.scenario_name || scenarioNames[s.scenario_id] || s.scenario_id.slice(0, 8),
|
||||||
|
pass_rate: s.pass_rate, availability: s.availability, avg_latency_ms: s.avg_latency_ms,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
const renderPair = (metric: typeof METRICS[number]) => (_: unknown, r: typeof rows[number]) => {
|
||||||
|
const pair: MetricDeltaPair = r[metric.key]
|
||||||
|
const good = metric.goodDirection === 'down' ? (pair.delta ?? 0) < 0 : (pair.delta ?? 0) > 0
|
||||||
|
const deltaColor = pair.delta == null || pair.delta === 0
|
||||||
|
? colors.textMuted
|
||||||
|
: good ? '#52c41a' : '#ff4d4f'
|
||||||
|
return (
|
||||||
|
<Space size={4}>
|
||||||
|
<span style={{ color: colors.textMuted }}>
|
||||||
|
{pair.baseline == null ? '—' : metric.fmt(pair.baseline)}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: colors.textMuted }}>→</span>
|
||||||
|
<span>{pair.current == null ? '—' : metric.fmt(pair.current)}</span>
|
||||||
|
<span style={{ color: deltaColor, fontWeight: 500 }}>
|
||||||
|
{pair.delta == null ? '—' : metric.fmtDelta(pair.delta)}
|
||||||
|
</span>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
rowKey="key"
|
||||||
|
size="small"
|
||||||
|
dataSource={rows}
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{ title: '维度', dataIndex: 'name', key: 'name' },
|
||||||
|
...METRICS.map((m) => ({ title: m.label, key: m.key, render: renderPair(m) })),
|
||||||
|
]}
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = comparison?.status ?? 'none'
|
||||||
|
if (status === 'generating') {
|
||||||
|
return (
|
||||||
|
<div style={boxStyle}>
|
||||||
|
<Spin size="small" />
|
||||||
|
<span style={{ marginLeft: 10, color: colors.textSecondary }}>
|
||||||
|
正在生成周期对比叙述,通常需要几十秒…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (status === 'failed') {
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Alert
|
||||||
|
type="error" showIcon message="周期对比生成失败"
|
||||||
|
description={comparison?.comparison?.error || '未知错误'}
|
||||||
|
action={button('重试', comparison?.comparison?.baseline_campaign_id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const entry = comparison?.comparison ?? null
|
||||||
|
if (!entry || !entry.result) {
|
||||||
|
const autoBaseline = comparison?.auto_baseline ?? null
|
||||||
|
return (
|
||||||
|
<div style={{ ...boxStyle, background: colors.bgSubtle }}>
|
||||||
|
<div style={{ color: colors.textSecondary, marginBottom: 8 }}>
|
||||||
|
{autoBaseline
|
||||||
|
? <>已找到自动基线「{autoBaseline.name}」({shortDateTime(autoBaseline.completed_at)}),可生成对比叙述。</>
|
||||||
|
: '未找到自动基线(需同计划、正式线、更早完成且已有智能分析的历史活动)。可手动选择基线。'}
|
||||||
|
</div>
|
||||||
|
<Space size={12} wrap>
|
||||||
|
{autoBaseline
|
||||||
|
? button('生成对比')
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
style={{ minWidth: 260 }}
|
||||||
|
placeholder="选择基线活动"
|
||||||
|
value={baselineChoice}
|
||||||
|
onChange={(v) => setBaselineChoice(v)}
|
||||||
|
options={baselineCandidates.map((c) => ({
|
||||||
|
label: `${c.name}(完成于 ${shortDateTime(c.completed_at)})`,
|
||||||
|
value: c.id,
|
||||||
|
}))}
|
||||||
|
notFoundContent="没有可选的历史活动"
|
||||||
|
/>
|
||||||
|
{button('生成对比', baselineChoice, true)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!analysisCompleted && (
|
||||||
|
<span style={{ fontSize: 12, color: colors.warning }}>请先完成智能分析后再生成对比</span>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
{comparison?.metric_diff && metricDiffTable(comparison.metric_diff)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const result = entry.result
|
||||||
|
const baseline = entry.baseline
|
||||||
|
const trendMeta = TREND_META[result.trend] ?? TREND_META.stable
|
||||||
|
const modelName = entry.model_config_id
|
||||||
|
? modelConfigs.find((m) => m.id === entry.model_config_id)?.name ?? entry.model_config_id.slice(0, 8)
|
||||||
|
: null
|
||||||
|
return (
|
||||||
|
<div style={boxStyle}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
fontSize: 12, color: colors.textSecondary, marginBottom: 10,
|
||||||
|
}}>
|
||||||
|
<span>
|
||||||
|
基线「{baseline?.name ?? entry.baseline_campaign_id.slice(0, 8)}」
|
||||||
|
{baseline?.completed_at && <>(完成于 {shortDateTime(baseline.completed_at)})</>}
|
||||||
|
{' · '}模型 {modelName ?? '未知'}
|
||||||
|
{entry.updated_at && <> · 生成于 {shortDateTime(entry.updated_at)}</>}
|
||||||
|
</span>
|
||||||
|
{button('重新生成', entry.baseline_campaign_id)}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
background: '#e6f4ff', border: '1px solid #91caff', borderRadius: 8,
|
||||||
|
padding: '10px 14px', marginBottom: 12,
|
||||||
|
}}>
|
||||||
|
<Space size={8} align="start">
|
||||||
|
<Tag color={trendMeta.color} style={{ margin: 0 }}>{trendMeta.label}</Tag>
|
||||||
|
<span><b>总体趋势:</b>{result.summary}</span>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
{comparison?.metric_diff && metricDiffTable(comparison.metric_diff)}
|
||||||
|
{result.problem_evolution.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<div style={{ fontWeight: 500, marginBottom: 8 }}>问题演变</div>
|
||||||
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||||
|
{result.problem_evolution.map((p, i) => {
|
||||||
|
const meta = EVOLUTION_META[p.status] ?? EVOLUTION_META.persisting
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ border: `1px solid ${colors.border}`, borderRadius: 8, padding: '8px 12px' }}>
|
||||||
|
<Space size={8} wrap>
|
||||||
|
<Tag color={meta.color} style={{ margin: 0 }}>{meta.label}</Tag>
|
||||||
|
<b>{p.title}</b>
|
||||||
|
{p.scenario_ids.map((sid) => (
|
||||||
|
<Tag key={sid} style={{ margin: 0 }}>{scenarioNames[sid] ?? sid.slice(0, 8)}</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
<div style={{ color: colors.textSecondary, marginTop: 6 }}>{p.detail}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result.suggestion_tracking.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, marginBottom: 8 }}>建议落实情况</div>
|
||||||
|
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||||
|
{result.suggestion_tracking.map((s, i) => {
|
||||||
|
const meta = TRACKING_META[s.status] ?? TRACKING_META.unaddressed
|
||||||
|
return (
|
||||||
|
<div key={i}>
|
||||||
|
<Space size={8} wrap>
|
||||||
|
<Tag color={meta.color} style={{ margin: 0 }}>{meta.label}</Tag>
|
||||||
|
<span>{s.text}</span>
|
||||||
|
</Space>
|
||||||
|
{s.note && (
|
||||||
|
<div style={{ color: colors.textSecondary, fontSize: 12, margin: '2px 0 0 22px' }}>{s.note}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -17,13 +17,14 @@ import {
|
|||||||
campaignsApi, targetsApi, scenariosApi, runsApi, modelConfigsApi,
|
campaignsApi, targetsApi, scenariosApi, runsApi, modelConfigsApi,
|
||||||
type CampaignListItem, type CampaignReport, type Target, type Scenario, type Run,
|
type CampaignListItem, type CampaignReport, type Target, type Scenario, type Run,
|
||||||
type CampaignTimelineEntry, type ModelConfig, type CampaignAnalysis,
|
type CampaignTimelineEntry, type ModelConfig, type CampaignAnalysis,
|
||||||
type CampaignComparison, type MetricDiff,
|
type CampaignComparison,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import { passRateColor } from '../utils/colors'
|
import { passRateColor } from '../utils/colors'
|
||||||
import { shortDateTime, toDate } from '../utils/date'
|
import { shortDateTime, toDate } from '../utils/date'
|
||||||
import { deriveTimeScale, acceleratedDuration, formatScale } from '../utils/campaignTime'
|
import { deriveTimeScale, acceleratedDuration, formatScale } from '../utils/campaignTime'
|
||||||
import WindowTimeline, { type TimelineMarker } from '../components/WindowTimeline'
|
import WindowTimeline, { type TimelineMarker } from '../components/WindowTimeline'
|
||||||
import CampaignRunTimeline from '../components/CampaignRunTimeline'
|
import CampaignRunTimeline from '../components/CampaignRunTimeline'
|
||||||
|
import PeriodComparisonSection from '../components/PeriodComparisonSection'
|
||||||
import { useResource } from '../hooks/useResource'
|
import { useResource } from '../hooks/useResource'
|
||||||
import { usePolling } from '../hooks/usePolling'
|
import { usePolling } from '../hooks/usePolling'
|
||||||
import { useTabStore } from '../stores/tabStore'
|
import { useTabStore } from '../stores/tabStore'
|
||||||
@ -43,25 +44,6 @@ const SEVERITY_META: Record<string, { label: string; color: string }> = {
|
|||||||
low: { label: '低', color: 'blue' },
|
low: { label: '低', color: 'blue' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const TREND_META: Record<string, { label: string; color: string }> = {
|
|
||||||
improving: { label: '改善', color: 'green' },
|
|
||||||
stable: { label: '平稳', color: 'blue' },
|
|
||||||
regressing: { label: '退化', color: 'red' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const EVOLUTION_META: Record<string, { label: string; color: string }> = {
|
|
||||||
new: { label: '新增', color: 'red' },
|
|
||||||
persisting: { label: '持续', color: 'orange' },
|
|
||||||
resolved: { label: '消解', color: 'green' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const TRACKING_META: Record<string, { label: string; color: string }> = {
|
|
||||||
addressed: { label: '已落实', color: 'green' },
|
|
||||||
partial: { label: '部分落实', color: 'orange' },
|
|
||||||
unaddressed: { label: '未落实', color: 'default' },
|
|
||||||
new: { label: '新增', color: 'blue' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const WINDOW_OPTIONS = [6, 12, 24, 48, 72].map((h) => ({ label: `${h} 小时`, value: h * 3600 }))
|
const WINDOW_OPTIONS = [6, 12, 24, 48, 72].map((h) => ({ label: `${h} 小时`, value: h * 3600 }))
|
||||||
|
|
||||||
const UNIT_OPTIONS = [
|
const UNIT_OPTIONS = [
|
||||||
@ -146,8 +128,6 @@ export default function CampaignsPage() {
|
|||||||
const [analysis, setAnalysis] = useState<CampaignAnalysis | null>(null)
|
const [analysis, setAnalysis] = useState<CampaignAnalysis | null>(null)
|
||||||
const [analysisBusy, setAnalysisBusy] = useState(false)
|
const [analysisBusy, setAnalysisBusy] = useState(false)
|
||||||
const [comparison, setComparison] = useState<CampaignComparison | null>(null)
|
const [comparison, setComparison] = useState<CampaignComparison | null>(null)
|
||||||
const [comparisonBusy, setComparisonBusy] = useState(false)
|
|
||||||
const [baselineChoice, setBaselineChoice] = useState<string | undefined>(undefined)
|
|
||||||
|
|
||||||
const [expandedIds, setExpandedIds] = useState<string[]>([])
|
const [expandedIds, setExpandedIds] = useState<string[]>([])
|
||||||
const [timelines, setTimelines] = useState<Record<string, CampaignTimelineEntry[]>>({})
|
const [timelines, setTimelines] = useState<Record<string, CampaignTimelineEntry[]>>({})
|
||||||
@ -257,7 +237,6 @@ export default function CampaignsPage() {
|
|||||||
setReportTimeline([])
|
setReportTimeline([])
|
||||||
setAnalysis(null)
|
setAnalysis(null)
|
||||||
setComparison(null)
|
setComparison(null)
|
||||||
setBaselineChoice(undefined)
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const [rep, runs, tl, ana, cmp] = await Promise.all([
|
const [rep, runs, tl, ana, cmp] = await Promise.all([
|
||||||
@ -317,14 +296,9 @@ export default function CampaignsPage() {
|
|||||||
|
|
||||||
const generateComparison = async (baselineCampaignId?: string) => {
|
const generateComparison = async (baselineCampaignId?: string) => {
|
||||||
if (!reportId) return
|
if (!reportId) return
|
||||||
setComparisonBusy(true)
|
await campaignsApi.generateComparison(reportId, baselineCampaignId)
|
||||||
try {
|
const res = await campaignsApi.getComparison(reportId)
|
||||||
await campaignsApi.generateComparison(reportId, baselineCampaignId)
|
setComparison(res.data)
|
||||||
const res = await campaignsApi.getComparison(reportId)
|
|
||||||
setComparison(res.data)
|
|
||||||
} finally {
|
|
||||||
setComparisonBusy(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 对比生成中同样轮询,直到 completed/failed。
|
// 对比生成中同样轮询,直到 completed/failed。
|
||||||
@ -685,231 +659,6 @@ export default function CampaignsPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 周期对比区块 ─────────────────────────────────────────────────────
|
|
||||||
const comparisonReady = analysis?.status === 'completed'
|
|
||||||
const comparisonModelName = comparison?.comparison?.model_config_id
|
|
||||||
? (data?.modelConfigs ?? []).find((m) => m.id === comparison.comparison?.model_config_id)?.name
|
|
||||||
?? comparison.comparison.model_config_id.slice(0, 8)
|
|
||||||
: null
|
|
||||||
|
|
||||||
const baselineCandidates = report
|
|
||||||
? campaigns
|
|
||||||
.filter((c) => c.target_id === report.target_id
|
|
||||||
&& c.id !== report.campaign_id
|
|
||||||
&& !isActiveStatus(c.status))
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => (b.completed_at ?? '').localeCompare(a.completed_at ?? ''))
|
|
||||||
: []
|
|
||||||
|
|
||||||
const comparisonButton = (label: string, baselineCampaignId?: string, requireBaseline = false) => (
|
|
||||||
<Tooltip title={
|
|
||||||
!analysisTerminal ? '活动完成后可生成'
|
|
||||||
: !comparisonReady ? '请先生成智能分析'
|
|
||||||
: requireBaseline && !baselineCampaignId ? '请先选择基线活动'
|
|
||||||
: undefined
|
|
||||||
}>
|
|
||||||
<Button
|
|
||||||
size="small" type="primary" ghost
|
|
||||||
loading={comparisonBusy}
|
|
||||||
disabled={!analysisTerminal || !comparisonReady || (requireBaseline && !baselineCampaignId)}
|
|
||||||
onClick={() => void generateComparison(baselineCampaignId)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
|
|
||||||
const fmtMetricValue = (metric: 'pass_rate' | 'availability' | 'avg_latency_ms', v: number | null): string => {
|
|
||||||
if (v == null) return '—'
|
|
||||||
return metric === 'avg_latency_ms' ? `${Math.round(v)}ms` : fmtPct(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fmtMetricDelta = (metric: 'pass_rate' | 'availability' | 'avg_latency_ms', d: number | null): string => {
|
|
||||||
if (d == null) return '—'
|
|
||||||
const sign = d > 0 ? '+' : ''
|
|
||||||
return metric === 'avg_latency_ms' ? `${sign}${Math.round(d * 10) / 10}ms` : `${sign}${(d * 100).toFixed(1)}pp`
|
|
||||||
}
|
|
||||||
|
|
||||||
const deltaColor = (metric: 'pass_rate' | 'availability' | 'avg_latency_ms', d: number | null): string => {
|
|
||||||
if (d == null || d === 0) return colors.textMuted
|
|
||||||
// 时延下降是好事;比率上升是好事。
|
|
||||||
const good = metric === 'avg_latency_ms' ? d < 0 : d > 0
|
|
||||||
return good ? '#52c41a' : '#ff4d4f'
|
|
||||||
}
|
|
||||||
|
|
||||||
const metricDiffTable = (diff: MetricDiff) => {
|
|
||||||
const rows = [
|
|
||||||
{ key: 'overall', name: '整窗(总体)', ...diff.overall },
|
|
||||||
...diff.scenarios.map((s) => ({
|
|
||||||
key: s.scenario_id,
|
|
||||||
name: s.scenario_name || scenarioNames[s.scenario_id] || s.scenario_id.slice(0, 8),
|
|
||||||
pass_rate: s.pass_rate, availability: s.availability, avg_latency_ms: s.avg_latency_ms,
|
|
||||||
})),
|
|
||||||
]
|
|
||||||
const metricCols = (['pass_rate', 'availability', 'avg_latency_ms'] as const).map((metric) => ({
|
|
||||||
title: metric === 'pass_rate' ? '通过率' : metric === 'availability' ? '可用性' : '平均时延',
|
|
||||||
key: metric,
|
|
||||||
render: (_: unknown, r: typeof rows[number]) => {
|
|
||||||
const pair = r[metric]
|
|
||||||
return (
|
|
||||||
<Space size={4}>
|
|
||||||
<span style={{ color: colors.textMuted }}>{fmtMetricValue(metric, pair.baseline)}</span>
|
|
||||||
<span style={{ color: colors.textMuted }}>→</span>
|
|
||||||
<span>{fmtMetricValue(metric, pair.current)}</span>
|
|
||||||
<span style={{ color: deltaColor(metric, pair.delta), fontWeight: 500 }}>
|
|
||||||
{fmtMetricDelta(metric, pair.delta)}
|
|
||||||
</span>
|
|
||||||
</Space>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
return (
|
|
||||||
<Table
|
|
||||||
rowKey="key"
|
|
||||||
size="small"
|
|
||||||
dataSource={rows}
|
|
||||||
pagination={false}
|
|
||||||
columns={[{ title: '维度', dataIndex: 'name', key: 'name' }, ...metricCols]}
|
|
||||||
style={{ marginBottom: 12 }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderComparisonSection = () => {
|
|
||||||
const status = comparison?.status ?? 'none'
|
|
||||||
if (status === 'generating') {
|
|
||||||
return (
|
|
||||||
<div style={analysisBoxStyle}>
|
|
||||||
<Spin size="small" />
|
|
||||||
<span style={{ marginLeft: 10, color: colors.textSecondary }}>
|
|
||||||
正在生成周期对比叙述,通常需要几十秒…
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (status === 'failed') {
|
|
||||||
return (
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<Alert
|
|
||||||
type="error" showIcon message="周期对比生成失败"
|
|
||||||
description={comparison?.comparison?.error || '未知错误'}
|
|
||||||
action={comparisonButton('重试', comparison?.comparison?.baseline_campaign_id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const entry = comparison?.comparison ?? null
|
|
||||||
if (!entry || !entry.result) {
|
|
||||||
const autoBaseline = comparison?.auto_baseline ?? null
|
|
||||||
return (
|
|
||||||
<div style={{ ...analysisBoxStyle, background: colors.bgSubtle }}>
|
|
||||||
<div style={{ color: colors.textSecondary, marginBottom: 8 }}>
|
|
||||||
{autoBaseline
|
|
||||||
? <>已找到自动基线「{autoBaseline.name}」({shortDateTime(autoBaseline.completed_at)}),可生成对比叙述。</>
|
|
||||||
: '未找到自动基线(需同计划、正式线、更早完成且已有智能分析的历史活动)。可手动选择基线。'}
|
|
||||||
</div>
|
|
||||||
<Space size={12} wrap>
|
|
||||||
{autoBaseline
|
|
||||||
? comparisonButton('生成对比')
|
|
||||||
: (
|
|
||||||
<>
|
|
||||||
<Select
|
|
||||||
size="small"
|
|
||||||
style={{ minWidth: 260 }}
|
|
||||||
placeholder="选择基线活动"
|
|
||||||
value={baselineChoice}
|
|
||||||
onChange={(v) => setBaselineChoice(v)}
|
|
||||||
options={baselineCandidates.map((c) => ({
|
|
||||||
label: `${c.name}(完成于 ${shortDateTime(c.completed_at)})`,
|
|
||||||
value: c.id,
|
|
||||||
}))}
|
|
||||||
notFoundContent="没有可选的历史活动"
|
|
||||||
/>
|
|
||||||
{comparisonButton('生成对比', baselineChoice, true)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!comparisonReady && (
|
|
||||||
<span style={{ fontSize: 12, color: colors.warning }}>请先完成智能分析后再生成对比</span>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
{comparison?.metric_diff && metricDiffTable(comparison.metric_diff)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const result = entry.result
|
|
||||||
const baseline = entry.baseline
|
|
||||||
const trendMeta = TREND_META[result.trend] ?? TREND_META.stable
|
|
||||||
return (
|
|
||||||
<div style={analysisBoxStyle}>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
|
||||||
fontSize: 12, color: colors.textSecondary, marginBottom: 10,
|
|
||||||
}}>
|
|
||||||
<span>
|
|
||||||
基线「{baseline?.name ?? entry.baseline_campaign_id.slice(0, 8)}」
|
|
||||||
{baseline?.completed_at && <>(完成于 {shortDateTime(baseline.completed_at)})</>}
|
|
||||||
{' · '}模型 {comparisonModelName ?? '未知'}
|
|
||||||
{entry.updated_at && <> · 生成于 {shortDateTime(entry.updated_at)}</>}
|
|
||||||
</span>
|
|
||||||
{comparisonButton('重新生成', entry.baseline_campaign_id)}
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
background: '#e6f4ff', border: '1px solid #91caff', borderRadius: 8,
|
|
||||||
padding: '10px 14px', marginBottom: 12,
|
|
||||||
}}>
|
|
||||||
<Space size={8} align="start">
|
|
||||||
<Tag color={trendMeta.color} style={{ margin: 0 }}>{trendMeta.label}</Tag>
|
|
||||||
<span><b>总体趋势:</b>{result.summary}</span>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
{comparison?.metric_diff && metricDiffTable(comparison.metric_diff)}
|
|
||||||
{result.problem_evolution.length > 0 && (
|
|
||||||
<div style={{ marginBottom: 12 }}>
|
|
||||||
<div style={{ fontWeight: 500, marginBottom: 8 }}>问题演变</div>
|
|
||||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
|
||||||
{result.problem_evolution.map((p, i) => {
|
|
||||||
const meta = EVOLUTION_META[p.status] ?? EVOLUTION_META.persisting
|
|
||||||
return (
|
|
||||||
<div key={i} style={{ border: `1px solid ${colors.border}`, borderRadius: 8, padding: '8px 12px' }}>
|
|
||||||
<Space size={8} wrap>
|
|
||||||
<Tag color={meta.color} style={{ margin: 0 }}>{meta.label}</Tag>
|
|
||||||
<b>{p.title}</b>
|
|
||||||
{p.scenario_ids.map((sid) => (
|
|
||||||
<Tag key={sid} style={{ margin: 0 }}>{scenarioNames[sid] ?? sid.slice(0, 8)}</Tag>
|
|
||||||
))}
|
|
||||||
</Space>
|
|
||||||
<div style={{ color: colors.textSecondary, marginTop: 6 }}>{p.detail}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{result.suggestion_tracking.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 500, marginBottom: 8 }}>建议落实情况</div>
|
|
||||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
|
||||||
{result.suggestion_tracking.map((s, i) => {
|
|
||||||
const meta = TRACKING_META[s.status] ?? TRACKING_META.unaddressed
|
|
||||||
return (
|
|
||||||
<div key={i}>
|
|
||||||
<Space size={8} wrap>
|
|
||||||
<Tag color={meta.color} style={{ margin: 0 }}>{meta.label}</Tag>
|
|
||||||
<span>{s.text}</span>
|
|
||||||
</Space>
|
|
||||||
{s.note && (
|
|
||||||
<div style={{ color: colors.textSecondary, fontSize: 12, margin: '2px 0 0 22px' }}>{s.note}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageWrapper
|
<PageWrapper
|
||||||
title="评估活动"
|
title="评估活动"
|
||||||
@ -1217,7 +966,18 @@ export default function CampaignsPage() {
|
|||||||
{renderAnalysisSection()}
|
{renderAnalysisSection()}
|
||||||
|
|
||||||
<SectionTitle><SwapOutlined /> 周期对比</SectionTitle>
|
<SectionTitle><SwapOutlined /> 周期对比</SectionTitle>
|
||||||
{renderComparisonSection()}
|
<PeriodComparisonSection
|
||||||
|
key={report.campaign_id}
|
||||||
|
comparison={comparison}
|
||||||
|
terminal={analysisTerminal}
|
||||||
|
analysisCompleted={analysis?.status === 'completed'}
|
||||||
|
targetId={report.target_id}
|
||||||
|
currentCampaignId={report.campaign_id}
|
||||||
|
campaigns={campaigns}
|
||||||
|
scenarioNames={scenarioNames}
|
||||||
|
modelConfigs={data?.modelConfigs ?? []}
|
||||||
|
onGenerate={generateComparison}
|
||||||
|
/>
|
||||||
|
|
||||||
<SectionTitle>过程时间轴</SectionTitle>
|
<SectionTitle>过程时间轴</SectionTitle>
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user