From a12eb09da3bdcb370bfc5e16157f55ab3e5e3e76 Mon Sep 17 00:00:00 2001 From: sinohqb Date: Tue, 4 Aug 2026 00:24:45 +0800 Subject: [PATCH] feat(exploration): report drawer exploration findings section with session drill-down --- backend/agenteval/web/routers/exploration.py | 22 ++ frontend/web/src/api.ts | 64 +++++ .../web/src/components/ExplorationSection.tsx | 237 ++++++++++++++++++ frontend/web/src/pages/Campaigns.tsx | 14 +- tests/integration/test_exploration_api.py | 60 +++++ 5 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 frontend/web/src/components/ExplorationSection.tsx diff --git a/backend/agenteval/web/routers/exploration.py b/backend/agenteval/web/routers/exploration.py index e975a86..dec9e9a 100644 --- a/backend/agenteval/web/routers/exploration.py +++ b/backend/agenteval/web/routers/exploration.py @@ -201,6 +201,28 @@ async def create_session( return repo.create(session_obj).model_dump(mode="json") +@router.get("/campaigns/{campaign_id}/sessions") +async def list_campaign_sessions( + campaign_id: str, + session: Session = Depends(get_db), +) -> dict: + if not CampaignRepository(session).get(campaign_id): + raise HTTPException(status_code=404, detail="campaign not found") + sessions = ExplorationSessionRepository(session).list_by_campaign(campaign_id) + return {"sessions": [s.model_dump(mode="json") for s in sessions]} + + +@router.get("/sessions/{session_id}/messages") +async def list_session_messages( + session_id: str, + session: Session = Depends(get_db), +) -> dict: + if not ExplorationSessionRepository(session).get(session_id): + raise HTTPException(status_code=404, detail="exploration session not found") + messages = ExplorationMessageRepository(session).list_by_session(session_id) + return {"messages": [m.model_dump(mode="json") for m in messages]} + + @router.post("/sessions/{session_id}/messages") async def send_session_message( session_id: str, diff --git a/frontend/web/src/api.ts b/frontend/web/src/api.ts index 906e945..526c6b2 100644 --- a/frontend/web/src/api.ts +++ b/frontend/web/src/api.ts @@ -324,6 +324,69 @@ export interface ExplorationBudgetConfig { min_interval_seconds?: number | null } +export interface ExplorationIssueCount { + issue: string + count: number +} + +export interface ExplorationJudgeReview { + reviewed_sessions: number + findings: { dimension: string; rating: string; comment: string }[] + summaries: string[] +} + +export interface ExplorationSummary { + session_count: number + sessions_with_experience: number + goal_achieved_count: number + goal_achievement_rate: number | null + issues: ExplorationIssueCount[] + misled: ExplorationIssueCount[] + judge_review: ExplorationJudgeReview | null +} + +export interface ExplorationExperience { + goal_achieved: boolean + blockers: string[] + misled: string[] + emotion: string + notes: string +} + +export interface ExplorationSession { + id: string + campaign_id: string + target_id: string + persona: Record + goal: string + seed_ref: Record | null + status: string + triggered_by: string + experience: ExplorationExperience | null + judge_review: Record | null + turn_count: number + error: string | null + created_at: string | null + closed_at: string | null +} + +export interface ExplorationMessage { + id: string + session_id: string + round_index: number + role: string + content: string + latency_ms: number | null + created_at: string | null +} + +export const explorationApi = { + listSessions: (campaignId: string) => + api.get<{ sessions: ExplorationSession[] }>(`/exploration/campaigns/${campaignId}/sessions`), + listMessages: (sessionId: string) => + api.get<{ messages: ExplorationMessage[] }>(`/exploration/sessions/${sessionId}/messages`), +} + export interface CampaignProgress { current_offset_seconds: number spawned_runs: number @@ -394,6 +457,7 @@ export interface CampaignReport { } time_trend: CampaignTrendBucket[] capability_summary: CampaignCapability[] + exploration?: ExplorationSummary | null } export interface CampaignTimelineEntry { diff --git a/frontend/web/src/components/ExplorationSection.tsx b/frontend/web/src/components/ExplorationSection.tsx new file mode 100644 index 0000000..50e6b4b --- /dev/null +++ b/frontend/web/src/components/ExplorationSection.tsx @@ -0,0 +1,237 @@ +import { useEffect, useState } from 'react' +import { Collapse, Empty, Space, Spin, Tag } from 'antd' +import { UserOutlined, RobotOutlined } from '@ant-design/icons' +import { + explorationApi, + type ExplorationMessage, + type ExplorationSession, + type ExplorationSummary, +} from '../api' +import { colors } from '../tokens' +import { shortDateTime } from '../utils/date' + +const SESSION_STATUS: Record = { + running: { label: '进行中', color: 'processing' }, + completed: { label: '已完成', color: 'success' }, + failed: { label: '失败', color: 'error' }, + expired: { label: '已过期', color: 'default' }, +} + +const EMOTION_LABELS: Record = { + positive: '满意', + neutral: '平静', + confused: '困惑', + frustrated: '沮丧', +} + +const DIMENSION_LABELS: Record = { + attitude: '服务态度', + professionalism: '专业度', + hallucination: '幻觉', +} + +function pct(rate: number | null): string { + return rate == null ? '—' : `${(rate * 100).toFixed(1)}%` +} + +function IssueList({ label, items, misled }: { + label: string + items: { issue: string; count: number }[] + misled?: boolean +}) { + if (items.length === 0) return null + return ( +
+
{label}
+ + {items.map((item, i) => ( + + {misled ? `(被误导)${item.issue}` : item.issue} ×{item.count} + + ))} + +
+ ) +} + +function SessionDetail({ session, messages }: { + session: ExplorationSession + messages: ExplorationMessage[] | null +}) { + const experience = session.experience + return ( +
+
+ 目标:{session.goal} + {session.created_at && <> · 开始于 {shortDateTime(session.created_at)}} + {session.closed_at && <> · 结束于 {shortDateTime(session.closed_at)}} +
+ + {!messages && } + {messages && messages.length === 0 && ( + + )} + {messages && messages.map((m) => ( +
+ {m.role === 'user' ? ( +
+ + {m.content} +
+ ) : ( +
+ + {m.content} + {m.latency_ms != null && ( + {m.latency_ms}ms + )} +
+ )} +
+ ))} + + {experience && ( +
+
体验记录
+ + {experience.goal_achieved + ? 目标达成 + : 目标未达成} + {EMOTION_LABELS[experience.emotion] ?? experience.emotion} + + {experience.blockers.length > 0 && ( +
障碍:{experience.blockers.join(';')}
+ )} + {experience.misled.length > 0 && ( +
被误导:{experience.misled.join(';')}
+ )} + {experience.notes && ( +
备注:{experience.notes}
+ )} +
+ )} + {session.error && ( +
会话异常:{session.error}
+ )} +
+ ) +} + +export default function ExplorationSection({ campaignId, summary }: { + campaignId: string + summary: ExplorationSummary +}) { + const [sessions, setSessions] = useState(null) + const [loading, setLoading] = useState(false) + const [messageCache, setMessageCache] = useState>({}) + + useEffect(() => { + let cancelled = false + setLoading(true) + explorationApi.listSessions(campaignId) + .then((res) => { if (!cancelled) setSessions(res.data.sessions) }) + .catch(() => undefined) // 拦截器已弹错;避免未处理 rejection + .finally(() => { if (!cancelled) setLoading(false) }) + return () => { cancelled = true } + }, [campaignId]) + + const onExpand = (keys: string | string[]) => { + for (const key of Array.isArray(keys) ? keys : [keys]) { + if (key in messageCache) continue + void explorationApi.listMessages(key) + .then((res) => { setMessageCache((prev) => ({ ...prev, [key]: res.data.messages })) }) + .catch(() => undefined) // 拦截器已弹错;收起再展开可重试 + } + } + + const judge = summary.judge_review + return ( +
+
+ 探索会话 {summary.session_count} 个 · 有体验记录 {summary.sessions_with_experience} 个 + {' · '}目标达成率 {pct(summary.goal_achievement_rate)}({summary.goal_achieved_count}/{summary.sessions_with_experience}) +
+ + + + {summary.issues.length === 0 && summary.misled.length === 0 && ( +
体验记录未报告问题
+ )} + + {judge && ( +
+
+ judge 复核({judge.reviewed_sessions} 个会话抽样) +
+ {judge.findings.length === 0 + ?
未发现问题
+ : ( + + {judge.findings.map((f, i) => ( +
+ + {DIMENSION_LABELS[f.dimension] ?? f.dimension} + + {' '}{f.comment} +
+ ))} +
+ )} + {judge.summaries.map((s, i) => ( +
+ 复核结论:{s} +
+ ))} +
+ )} + + {loading && } + {!loading && sessions && sessions.length === 0 && ( + + )} + {sessions && sessions.length > 0 && ( + { + const meta = SESSION_STATUS[s.status] ?? SESSION_STATUS.running + const personaName = typeof s.persona?.name === 'string' ? s.persona.name : s.id.slice(0, 8) + return { + key: s.id, + label: ( + + {personaName} + {meta.label} + {s.turn_count} 轮 + {s.experience?.goal_achieved != null && ( + s.experience.goal_achieved + ? 目标达成 + : 目标未达成 + )} + + ), + children: , + } + })} + /> + )} +
+ ) +} diff --git a/frontend/web/src/pages/Campaigns.tsx b/frontend/web/src/pages/Campaigns.tsx index 0504469..69fa8bd 100644 --- a/frontend/web/src/pages/Campaigns.tsx +++ b/frontend/web/src/pages/Campaigns.tsx @@ -8,7 +8,7 @@ import { PlusOutlined, ReloadOutlined, StopOutlined, BarChartOutlined, FileMarkdownOutlined, MinusCircleOutlined, QuestionCircleOutlined, RocketOutlined, CheckCircleOutlined, SafetyOutlined, ClockCircleOutlined, - RobotOutlined, SwapOutlined, + RobotOutlined, SwapOutlined, CompassOutlined, } from '@ant-design/icons' import { Line, Bar } from '@ant-design/charts' import PageWrapper from '../components/PageWrapper' @@ -25,6 +25,7 @@ import { deriveTimeScale, acceleratedDuration, formatScale } from '../utils/camp import WindowTimeline, { type TimelineMarker } from '../components/WindowTimeline' import CampaignRunTimeline from '../components/CampaignRunTimeline' import PeriodComparisonSection from '../components/PeriodComparisonSection' +import ExplorationSection from '../components/ExplorationSection' import { useResource } from '../hooks/useResource' import { usePolling } from '../hooks/usePolling' import { useTabStore } from '../stores/tabStore' @@ -1055,6 +1056,17 @@ export default function CampaignsPage() { onGenerate={generateComparison} /> + {report.exploration && ( + <> + 探索发现 + + + )} + 过程时间轴