feat(exploration): report drawer exploration findings section with session drill-down
This commit is contained in:
parent
51980016a4
commit
a12eb09da3
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
237
frontend/web/src/components/ExplorationSection.tsx
Normal file
237
frontend/web/src/components/ExplorationSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user