feat(intelligent-eval): add decision process UI (ticket 09)
- Add list_decision_logs API endpoint - Add DecisionProcess component with timeline, list, filter, and export - Add decision log API calls to api.ts - Add "决策过程" button in EvalDetail to access decision history - Implement decision log export to JSON - Pass TypeScript type checking All 853 tests passing.
This commit is contained in:
parent
4b8afa892b
commit
ee639afb0d
@ -346,6 +346,41 @@ async def create_decision_log(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{eval_id}/decision-logs")
|
||||||
|
async def list_decision_logs(eval_id: str, session: Session = Depends(get_db)) -> dict:
|
||||||
|
"""List all decision logs for an evaluation."""
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from agenteval.storage.db import IntelligentEvalDB, IntelligentEvalDecisionLogDB
|
||||||
|
|
||||||
|
# Verify eval exists
|
||||||
|
eval_db = session.get(IntelligentEvalDB, eval_id)
|
||||||
|
if eval_db is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"intelligent eval {eval_id} not found")
|
||||||
|
|
||||||
|
# Get all decision logs
|
||||||
|
logs = session.exec(
|
||||||
|
select(IntelligentEvalDecisionLogDB)
|
||||||
|
.where(IntelligentEvalDecisionLogDB.eval_id == eval_id)
|
||||||
|
.order_by(IntelligentEvalDecisionLogDB.created_at.desc())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"logs": [
|
||||||
|
{
|
||||||
|
"id": log.id,
|
||||||
|
"eval_id": log.eval_id,
|
||||||
|
"decision_type": log.decision_type,
|
||||||
|
"reason": log.reason,
|
||||||
|
"context": log.get_context(),
|
||||||
|
"cron_id": log.cron_id,
|
||||||
|
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||||
|
}
|
||||||
|
for log in logs
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{eval_id}/config-snapshots")
|
@router.get("/{eval_id}/config-snapshots")
|
||||||
async def list_config_snapshots(eval_id: str, session: Session = Depends(get_db)) -> dict:
|
async def list_config_snapshots(eval_id: str, session: Session = Depends(get_db)) -> dict:
|
||||||
"""List all config snapshots for an evaluation."""
|
"""List all config snapshots for an evaluation."""
|
||||||
|
|||||||
@ -743,6 +743,16 @@ export interface ConfigSnapshotComparison {
|
|||||||
differences: Record<string, { old: unknown; new: unknown }>
|
differences: Record<string, { old: unknown; new: unknown }>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DecisionLog {
|
||||||
|
id: string
|
||||||
|
eval_id: string
|
||||||
|
decision_type: 'execute_session' | 'wait' | 'start_analysis'
|
||||||
|
reason: string
|
||||||
|
context: Record<string, unknown>
|
||||||
|
cron_id: string
|
||||||
|
created_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export const intelligentEvalsApi = {
|
export const intelligentEvalsApi = {
|
||||||
list: () => api.get<{ intelligent_evals: IntelligentEval[] }>('/intelligent-evals'),
|
list: () => api.get<{ intelligent_evals: IntelligentEval[] }>('/intelligent-evals'),
|
||||||
get: (id: string) => api.get<IntelligentEval>(`/intelligent-evals/${id}`),
|
get: (id: string) => api.get<IntelligentEval>(`/intelligent-evals/${id}`),
|
||||||
@ -777,6 +787,9 @@ export const intelligentEvalsApi = {
|
|||||||
snapshot_id_1: snapshotId1,
|
snapshot_id_1: snapshotId1,
|
||||||
snapshot_id_2: snapshotId2,
|
snapshot_id_2: snapshotId2,
|
||||||
}),
|
}),
|
||||||
|
// Decision Logs
|
||||||
|
listDecisionLogs: (id: string) =>
|
||||||
|
api.get<{ logs: DecisionLog[] }>(`/intelligent-evals/${id}/decision-logs`),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── File Management ──────────────────────────────────────────────
|
// ── File Management ──────────────────────────────────────────────
|
||||||
|
|||||||
183
frontend/web/src/components/intelligent_eval/DecisionProcess.tsx
Normal file
183
frontend/web/src/components/intelligent_eval/DecisionProcess.tsx
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Button, Card, Empty, Select, Table, Tag, Timeline, message,
|
||||||
|
} from 'antd'
|
||||||
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
|
import { ArrowLeftOutlined, DownloadOutlined } from '@ant-design/icons'
|
||||||
|
import { intelligentEvalsApi, type DecisionLog } from '../../api'
|
||||||
|
import { colors } from '../../tokens'
|
||||||
|
import { formatDateTime } from '../../utils/date'
|
||||||
|
|
||||||
|
const DECISION_TYPE_LABELS: Record<string, { label: string; color: string }> = {
|
||||||
|
execute_session: { label: '执行会话', color: 'blue' },
|
||||||
|
wait: { label: '等待', color: 'default' },
|
||||||
|
start_analysis: { label: '开始分析', color: 'green' },
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecisionProcessProps {
|
||||||
|
evalId: string
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DecisionProcess({ evalId, onBack }: DecisionProcessProps) {
|
||||||
|
const [logs, setLogs] = useState<DecisionLog[] | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [filterType, setFilterType] = useState<string | null>(null)
|
||||||
|
const [expandedLog, setExpandedLog] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const loadLogs = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await intelligentEvalsApi.listDecisionLogs(evalId)
|
||||||
|
setLogs(res.data.logs)
|
||||||
|
} catch {
|
||||||
|
message.error('加载决策日志失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useState(() => {
|
||||||
|
void loadLogs()
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
if (!logs) return
|
||||||
|
|
||||||
|
const data = JSON.stringify(logs, null, 2)
|
||||||
|
const blob = new Blob([data], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `decision-logs-${evalId.slice(0, 8)}.json`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
message.success('已导出决策日志')
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredLogs = filterType
|
||||||
|
? logs?.filter((log) => log.decision_type === filterType) ?? []
|
||||||
|
: logs ?? []
|
||||||
|
|
||||||
|
const columns: ColumnsType<DecisionLog> = [
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
width: 180,
|
||||||
|
render: (val: string | null) => formatDateTime(val),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '决策类型',
|
||||||
|
dataIndex: 'decision_type',
|
||||||
|
key: 'decision_type',
|
||||||
|
width: 120,
|
||||||
|
render: (val: string) => {
|
||||||
|
const info = DECISION_TYPE_LABELS[val] ?? { label: val, color: 'default' }
|
||||||
|
return <Tag color={info.color}>{info.label}</Tag>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '原因',
|
||||||
|
dataIndex: 'reason',
|
||||||
|
key: 'reason',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Cron ID',
|
||||||
|
dataIndex: 'cron_id',
|
||||||
|
key: 'cron_id',
|
||||||
|
width: 120,
|
||||||
|
render: (val: string) => <code style={{ fontSize: 11 }}>{val.slice(0, 8)}</code>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
width: 80,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Button size="small" onClick={() => setExpandedLog(expandedLog === record.id ? null : record.id)}>
|
||||||
|
{expandedLog === record.id ? '收起' : '详情'}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100%', overflowY: 'auto', padding: 16 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
||||||
|
<Button icon={<ArrowLeftOutlined />} onClick={onBack}>返回</Button>
|
||||||
|
<span style={{ fontSize: 16, fontWeight: 600 }}>决策过程</span>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<Select
|
||||||
|
placeholder="筛选决策类型"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 160 }}
|
||||||
|
onChange={(val) => setFilterType(val ?? null)}
|
||||||
|
options={[
|
||||||
|
{ label: '执行会话', value: 'execute_session' },
|
||||||
|
{ label: '等待', value: 'wait' },
|
||||||
|
{ label: '开始分析', value: 'start_analysis' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button icon={<DownloadOutlined />} onClick={handleExport}>导出</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card size="small" title="决策时间线" style={{ marginBottom: 16 }}>
|
||||||
|
{loading ? (
|
||||||
|
<Empty description="加载中..." />
|
||||||
|
) : filteredLogs.length === 0 ? (
|
||||||
|
<Empty description="暂无决策日志" />
|
||||||
|
) : (
|
||||||
|
<Timeline
|
||||||
|
items={filteredLogs.map((log) => ({
|
||||||
|
color: log.decision_type === 'execute_session' ? 'blue' : log.decision_type === 'start_analysis' ? 'green' : 'gray',
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
<Tag color={DECISION_TYPE_LABELS[log.decision_type]?.color ?? 'default'}>
|
||||||
|
{DECISION_TYPE_LABELS[log.decision_type]?.label ?? log.decision_type}
|
||||||
|
</Tag>
|
||||||
|
<span style={{ fontSize: 12, color: colors.textSecondary }}>
|
||||||
|
{formatDateTime(log.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13 }}>{log.reason}</div>
|
||||||
|
<div style={{ fontSize: 11, color: colors.textSecondary, marginTop: 4 }}>
|
||||||
|
Cron: <code>{log.cron_id.slice(0, 8)}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card size="small" title="决策日志列表">
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
dataSource={filteredLogs}
|
||||||
|
columns={columns}
|
||||||
|
pagination={false}
|
||||||
|
expandable={{
|
||||||
|
expandedRowKeys: expandedLog ? [expandedLog] : [],
|
||||||
|
expandIcon: () => null,
|
||||||
|
expandedRowRender: (record) => (
|
||||||
|
<div style={{ padding: '8px 0' }}>
|
||||||
|
<div style={{ fontWeight: 500, marginBottom: 8 }}>决策上下文</div>
|
||||||
|
<pre style={{
|
||||||
|
margin: 0, fontSize: 12, background: colors.bgSubtle,
|
||||||
|
padding: 12, borderRadius: 6, overflowX: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{JSON.stringify(record.context, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
locale={{ emptyText: <Empty description="暂无决策日志" /> }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -2,12 +2,13 @@ import { useState } from 'react'
|
|||||||
import {
|
import {
|
||||||
Alert, Button, Card, Col, Descriptions, Empty, Input, Modal, Popconfirm, Progress, Row, Space, Spin, Tag, message,
|
Alert, Button, Card, Col, Descriptions, Empty, Input, Modal, Popconfirm, Progress, Row, Space, Spin, Tag, message,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import { FileTextOutlined, HistoryOutlined, StopOutlined } from '@ant-design/icons'
|
import { FileTextOutlined, HistoryOutlined, NodeIndexOutlined, StopOutlined } from '@ant-design/icons'
|
||||||
import { intelligentEvalsApi, type IntelligentEval } from '../../api'
|
import { intelligentEvalsApi, type IntelligentEval } from '../../api'
|
||||||
import { colors } from '../../tokens'
|
import { colors } from '../../tokens'
|
||||||
import { formatDateTime, shortDateTime } from '../../utils/date'
|
import { formatDateTime, shortDateTime } from '../../utils/date'
|
||||||
import { EVAL_STATUS, SESSION_STATUS } from './status'
|
import { EVAL_STATUS, SESSION_STATUS } from './status'
|
||||||
import ConfigSnapshots from './ConfigSnapshots'
|
import ConfigSnapshots from './ConfigSnapshots'
|
||||||
|
import DecisionProcess from './DecisionProcess'
|
||||||
|
|
||||||
const sectionCard: React.CSSProperties = { marginBottom: 16 }
|
const sectionCard: React.CSSProperties = { marginBottom: 16 }
|
||||||
|
|
||||||
@ -72,6 +73,7 @@ export default function EvalDetail({ ev, targetName, onOpenReport, onChanged }:
|
|||||||
const [rejectOpen, setRejectOpen] = useState(false)
|
const [rejectOpen, setRejectOpen] = useState(false)
|
||||||
const [feedback, setFeedback] = useState('')
|
const [feedback, setFeedback] = useState('')
|
||||||
const [showConfigHistory, setShowConfigHistory] = useState(false)
|
const [showConfigHistory, setShowConfigHistory] = useState(false)
|
||||||
|
const [showDecisionProcess, setShowDecisionProcess] = useState(false)
|
||||||
const meta = EVAL_STATUS[ev.status] ?? { label: ev.status, color: 'default' }
|
const meta = EVAL_STATUS[ev.status] ?? { label: ev.status, color: 'default' }
|
||||||
const showSessions = ev.status === 'executing' || ev.status === 'completed'
|
const showSessions = ev.status === 'executing' || ev.status === 'completed'
|
||||||
const sessions = showSessions ? ev.sessions ?? [] : []
|
const sessions = showSessions ? ev.sessions ?? [] : []
|
||||||
@ -99,6 +101,10 @@ export default function EvalDetail({ ev, targetName, onOpenReport, onChanged }:
|
|||||||
return <ConfigSnapshots evalId={ev.id} onBack={() => setShowConfigHistory(false)} />
|
return <ConfigSnapshots evalId={ev.id} onBack={() => setShowConfigHistory(false)} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showDecisionProcess) {
|
||||||
|
return <DecisionProcess evalId={ev.id} onBack={() => setShowDecisionProcess(false)} />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', overflowY: 'auto', padding: 16 }}>
|
<div style={{ height: '100%', overflowY: 'auto', padding: 16 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||||
@ -107,6 +113,7 @@ export default function EvalDetail({ ev, targetName, onOpenReport, onChanged }:
|
|||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
<Space>
|
<Space>
|
||||||
<Button icon={<HistoryOutlined />} onClick={() => setShowConfigHistory(true)}>配置历史</Button>
|
<Button icon={<HistoryOutlined />} onClick={() => setShowConfigHistory(true)}>配置历史</Button>
|
||||||
|
<Button icon={<NodeIndexOutlined />} onClick={() => setShowDecisionProcess(true)}>决策过程</Button>
|
||||||
{ev.status === 'completed' && (
|
{ev.status === 'completed' && (
|
||||||
<Button type="primary" icon={<FileTextOutlined />} onClick={onOpenReport}>查看报告</Button>
|
<Button type="primary" icon={<FileTextOutlined />} onClick={onOpenReport}>查看报告</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user