feat(exploration): report drawer exploration findings section with session drill-down

This commit is contained in:
sinohqb 2026-08-04 00:24:45 +08:00
parent 51980016a4
commit a12eb09da3
5 changed files with 396 additions and 1 deletions

View File

@ -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,

View File

@ -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<string, unknown>
goal: string
seed_ref: Record<string, unknown> | null
status: string
triggered_by: string
experience: ExplorationExperience | null
judge_review: Record<string, unknown> | 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 {

View File

@ -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<string, { label: string; color: string }> = {
running: { label: '进行中', color: 'processing' },
completed: { label: '已完成', color: 'success' },
failed: { label: '失败', color: 'error' },
expired: { label: '已过期', color: 'default' },
}
const EMOTION_LABELS: Record<string, string> = {
positive: '满意',
neutral: '平静',
confused: '困惑',
frustrated: '沮丧',
}
const DIMENSION_LABELS: Record<string, string> = {
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 (
<div style={{ marginBottom: 10 }}>
<div style={{ fontSize: 12, color: colors.textSecondary, marginBottom: 6 }}>{label}</div>
<Space size={8} wrap>
{items.map((item, i) => (
<Tag key={i} color={misled ? 'orange' : 'red'}>
{misled ? `(被误导)${item.issue}` : item.issue} ×{item.count}
</Tag>
))}
</Space>
</div>
)
}
function SessionDetail({ session, messages }: {
session: ExplorationSession
messages: ExplorationMessage[] | null
}) {
const experience = session.experience
return (
<div>
<div style={{ fontSize: 12, color: colors.textSecondary, marginBottom: 10 }}>
{session.goal}
{session.created_at && <> · {shortDateTime(session.created_at)}</>}
{session.closed_at && <> · {shortDateTime(session.closed_at)}</>}
</div>
{!messages && <Spin size="small" />}
{messages && messages.length === 0 && (
<Empty description="暂无对话记录" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
{messages && messages.map((m) => (
<div
key={m.id}
style={{ display: 'flex', justifyContent: m.role === 'user' ? 'flex-start' : 'flex-end', marginBottom: 8 }}
>
{m.role === 'user' ? (
<div style={{
background: colors.chatUser, padding: '8px 14px',
borderRadius: '12px 12px 12px 2px', maxWidth: '70%',
fontSize: 13, lineHeight: 1.6,
}}>
<UserOutlined style={{ marginRight: 6, color: '#1677ff' }} />
{m.content}
</div>
) : (
<div style={{
background: colors.chatAgent, padding: '8px 14px',
borderRadius: '12px 12px 2px 12px', maxWidth: '70%',
fontSize: 13, lineHeight: 1.6,
}}>
<RobotOutlined style={{ marginRight: 6, color: '#52c41a' }} />
{m.content}
{m.latency_ms != null && (
<span style={{ color: colors.textMuted, fontSize: 11, marginLeft: 8 }}>{m.latency_ms}ms</span>
)}
</div>
)}
</div>
))}
{experience && (
<div style={{
marginTop: 8, border: `1px solid ${colors.border}`, borderRadius: 8,
background: colors.bgSubtle, padding: '10px 12px', fontSize: 13, lineHeight: 1.8,
}}>
<div style={{ fontWeight: 500, marginBottom: 4 }}></div>
<Space size={8} wrap>
{experience.goal_achieved
? <Tag color="success"></Tag>
: <Tag color="error"></Tag>}
<Tag>{EMOTION_LABELS[experience.emotion] ?? experience.emotion}</Tag>
</Space>
{experience.blockers.length > 0 && (
<div style={{ color: colors.textSecondary }}>{experience.blockers.join('')}</div>
)}
{experience.misled.length > 0 && (
<div style={{ color: colors.warning }}>{experience.misled.join('')}</div>
)}
{experience.notes && (
<div style={{ color: colors.textSecondary }}>{experience.notes}</div>
)}
</div>
)}
{session.error && (
<div style={{ marginTop: 8, fontSize: 12, color: colors.textMuted }}>{session.error}</div>
)}
</div>
)
}
export default function ExplorationSection({ campaignId, summary }: {
campaignId: string
summary: ExplorationSummary
}) {
const [sessions, setSessions] = useState<ExplorationSession[] | null>(null)
const [loading, setLoading] = useState(false)
const [messageCache, setMessageCache] = useState<Record<string, ExplorationMessage[]>>({})
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 (
<div style={{
border: `1px solid ${colors.border}`, borderRadius: 8,
padding: '12px 14px', marginBottom: 16,
}}>
<div style={{ fontSize: 12, color: colors.textSecondary, marginBottom: 10 }}>
{summary.session_count} · {summary.sessions_with_experience}
{' · '} {pct(summary.goal_achievement_rate)}{summary.goal_achieved_count}/{summary.sessions_with_experience}
</div>
<IssueList label="问题清单(体验记录)" items={summary.issues} />
<IssueList label="被误导清单(体验记录)" items={summary.misled} misled />
{summary.issues.length === 0 && summary.misled.length === 0 && (
<div style={{ fontSize: 12, color: colors.textMuted, marginBottom: 10 }}></div>
)}
{judge && (
<div style={{ marginBottom: 10 }}>
<div style={{ fontSize: 12, color: colors.textSecondary, marginBottom: 6 }}>
judge {judge.reviewed_sessions}
</div>
{judge.findings.length === 0
? <div style={{ fontSize: 13, color: colors.textSecondary }}></div>
: (
<Space direction="vertical" size={4} style={{ width: '100%' }}>
{judge.findings.map((f, i) => (
<div key={i} style={{ fontSize: 13 }}>
<Tag color="red" style={{ margin: 0 }}>
{DIMENSION_LABELS[f.dimension] ?? f.dimension}
</Tag>
{' '}{f.comment}
</div>
))}
</Space>
)}
{judge.summaries.map((s, i) => (
<div key={i} style={{ fontSize: 13, color: colors.textSecondary, marginTop: 4 }}>
{s}
</div>
))}
</div>
)}
{loading && <Spin size="small" />}
{!loading && sessions && sessions.length === 0 && (
<Empty description="暂无探索会话" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
{sessions && sessions.length > 0 && (
<Collapse
size="small"
onChange={onExpand}
items={sessions.map((s) => {
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: (
<Space size={8} wrap>
<span style={{ fontWeight: 500 }}>{personaName}</span>
<Tag color={meta.color}>{meta.label}</Tag>
<Tag>{s.turn_count} </Tag>
{s.experience?.goal_achieved != null && (
s.experience.goal_achieved
? <Tag color="success"></Tag>
: <Tag color="error"></Tag>
)}
</Space>
),
children: <SessionDetail session={s} messages={messageCache[s.id] ?? null} />,
}
})}
/>
)}
</div>
)
}

View File

@ -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 && (
<>
<SectionTitle><CompassOutlined /> </SectionTitle>
<ExplorationSection
key={report.campaign_id}
campaignId={report.campaign_id}
summary={report.exploration}
/>
</>
)}
<SectionTitle></SectionTitle>
<div style={{
border: `1px solid ${colors.border}`, borderRadius: 8,

View File

@ -349,6 +349,66 @@ async def test_close_triggers_judge_review(seeded_db, mock_channel, client, monk
assert started == [session_id]
# ---------------------------------------------------------------- read endpoints (ticket 06)
async def test_list_campaign_sessions_returns_lifecycle_fields(seeded_db, mock_channel, client):
resp = await _create_session(client)
assert resp.status_code == 200, resp.text
session_id = resp.json()["id"]
await client.post(f"/api/exploration/sessions/{session_id}/messages", json={"content": "查账单"})
resp = await client.post(
f"/api/exploration/sessions/{session_id}/close",
json={"experience": {"goal_achieved": True, "blockers": [], "misled": ["跳转误导"], "notes": "绕了三圈"}},
)
assert resp.status_code == 200, resp.text
resp = await client.get("/api/exploration/campaigns/c-1/sessions")
assert resp.status_code == 200, resp.text
sessions = resp.json()["sessions"]
assert len(sessions) == 1
entry = sessions[0]
assert entry["id"] == session_id
assert entry["status"] == "completed"
assert entry["turn_count"] == 1
assert entry["goal"] == "查询本月账单并完成缴费"
assert entry["persona"]["name"] == "急性子用户"
assert entry["experience"]["goal_achieved"] is True
assert entry["experience"]["misled"] == ["跳转误导"]
assert entry["closed_at"] is not None
async def test_list_sessions_empty_campaign(seeded_db, client):
resp = await client.get("/api/exploration/campaigns/c-1/sessions")
assert resp.status_code == 200, resp.text
assert resp.json() == {"sessions": []}
async def test_list_session_messages_returns_conversation(seeded_db, mock_channel, client):
session_id = (await _create_session(client)).json()["id"]
await client.post(f"/api/exploration/sessions/{session_id}/messages", json={"content": "我要查账单"})
resp = await client.get(f"/api/exploration/sessions/{session_id}/messages")
assert resp.status_code == 200, resp.text
messages = resp.json()["messages"]
assert len(messages) == 2
user_msg, agent_msg = messages
assert user_msg["role"] == "user"
assert user_msg["content"] == "我要查账单"
assert user_msg["round_index"] == 1
assert agent_msg["role"] == "assistant"
assert agent_msg["content"] == "您好,请问有什么可以帮您?"
assert agent_msg["round_index"] == 1
assert isinstance(agent_msg["latency_ms"], int)
assert user_msg["created_at"] is not None
async def test_list_messages_unknown_session_returns_404(seeded_db, client):
resp = await client.get("/api/exploration/sessions/nope/messages")
assert resp.status_code == 404
# ---------------------------------------------------------------- migration