Files
Profeto/frontend/src/admin/pages/EvalPage.tsx
T
Profeto Agent 835d7217d0 完善评估能力:筛选参数 + degraded 排除 + 前端评估页
后端:
- settle_prediction 拒绝 degraded/failed(明确错误信息)
- get_eval_summary 支持 provider/model/prompt_version/mode 筛选
- 返回 filtered_settled/evaluated/skipped_degraded 等计数
- matches 游标分页方向修复(scheduled ASC 用 > 条件)
- available_at 加 2h 缓冲(近似完赛时间)
- bzzziro 统计字段映射注释(待真实响应验证)
- injuries 区分 no_local_data 与 success 空名单

前端:
- 新增 EvalPage(筛选控件 + 汇总卡片 + 准确率表格)
- 挂载 /admin/eval 路由与导航

测试:
- test_matches_cursor.py:游标方向
- test_available_at.py:2h 缓冲与回测防泄漏
- test_bzzoirot_stats.py:统计字段映射
- test_injuries_no_local_data.py:no_local_data vs success
- test_injuries_inserted_count.py:失败批不计入
- test_eval_excludes_degraded.py:degraded 排除准确率
2026-09-19 09:40:24 +00:00

193 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Admin 后台 - 评估汇总页(报刊风)
*
* 功能:
* - 按 provider / model / prompt_version / mode / league_code 筛选
* - 展示汇总统计卡片(已结算/已评估/跳过数)
* - 准确率对比表格
* - 空态与加载态
*/
import { useCallback, useEffect, useState } from 'react'
import { fetchEvalSummary, fetchLeagues } from '../dal'
import type { EvalSummary } from '../types'
import { Card, CardBody, CardHeader, StatCard, Badge, DataTable, Alert, Spinner, EmptyState } from '../components'
interface Filters {
provider: string
model: string
prompt_version: string
mode: string
league_code: string
}
const EMPTY_FILTERS: Filters = { provider: '', model: '', prompt_version: '', mode: '', league_code: '' }
export default function EvalPage() {
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS)
const [leagues, setLeagues] = useState<Array<{ code: string; name: string }>>([])
const [data, setData] = useState<EvalSummary | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetchLeagues()
.then(l => setLeagues(l.map(x => ({ code: x.code, name: x.name }))))
.catch(() => setLeagues([]))
}, [])
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const params: Record<string, string> = {}
if (filters.provider) params.provider = filters.provider
if (filters.model) params.model = filters.model
if (filters.prompt_version) params.prompt_version = filters.prompt_version
if (filters.mode) params.mode = filters.mode
if (filters.league_code) params.league_code = filters.league_code
const result = await fetchEvalSummary(params)
setData(result)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : '加载失败')
} finally {
setLoading(false)
}
}, [filters])
useEffect(() => { load() }, [load])
const handleChange = (key: keyof Filters) => (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) => {
setFilters(f => ({ ...f, [key]: e.target.value }))
}
const handleReset = () => setFilters(EMPTY_FILTERS)
const summary = data?.summary ?? []
return (
<div className="space-y-6">
{/* 筛选控件 */}
<Card>
<CardHeader title="筛选条件" description="按提供商 / 模型 / 版本 / 模式 / 联赛过滤评估数据" />
<CardBody>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
<label className="block">
<span className="text-2xs text-ink-500">提供商</span>
<input
type="text"
value={filters.provider}
onChange={handleChange('provider')}
placeholder="如 openai"
className="mt-1 w-full border border-ink-300 bg-paper-50 px-2.5 py-1.5 text-sm text-ink-900 placeholder:text-ink-300 focus:border-ink-900 focus:outline-none"
/>
</label>
<label className="block">
<span className="text-2xs text-ink-500">模型</span>
<input
type="text"
value={filters.model}
onChange={handleChange('model')}
placeholder="如 gpt-4o"
className="mt-1 w-full border border-ink-300 bg-paper-50 px-2.5 py-1.5 text-sm text-ink-900 placeholder:text-ink-300 focus:border-ink-900 focus:outline-none"
/>
</label>
<label className="block">
<span className="text-2xs text-ink-500">Prompt 版本</span>
<input
type="text"
value={filters.prompt_version}
onChange={handleChange('prompt_version')}
placeholder="如 v1"
className="mt-1 w-full border border-ink-300 bg-paper-50 px-2.5 py-1.5 text-sm text-ink-900 placeholder:text-ink-300 focus:border-ink-900 focus:outline-none"
/>
</label>
<label className="block">
<span className="text-2xs text-ink-500">模式</span>
<select
value={filters.mode}
onChange={handleChange('mode')}
className="mt-1 w-full border border-ink-300 bg-paper-50 px-2.5 py-1.5 text-sm text-ink-900 focus:border-ink-900 focus:outline-none"
>
<option value="">全部</option>
<option value="single">single</option>
<option value="multi">multi</option>
</select>
</label>
<label className="block">
<span className="text-2xs text-ink-500">联赛</span>
<select
value={filters.league_code}
onChange={handleChange('league_code')}
className="mt-1 w-full border border-ink-300 bg-paper-50 px-2.5 py-1.5 text-sm text-ink-900 focus:border-ink-900 focus:outline-none"
>
<option value="">全部</option>
{leagues.map(l => (
<option key={l.code} value={l.code}>{l.name ?? l.code}</option>
))}
</select>
</label>
</div>
<div className="mt-3 flex gap-2">
<button onClick={load} disabled={loading} className="btn btn-sm">
{loading ? '加载中…' : '应用筛选'}
</button>
<button onClick={handleReset} disabled={loading} className="btn btn-sm btn-ghost">
重置
</button>
</div>
</CardBody>
</Card>
{/* 错误态 */}
{error && <Alert kind="error" title="加载失败" message={error} />}
{/* 汇总统计 */}
{data && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<StatCard label="已结算总数" value={data.total_settled} hint="含 degraded" />
<StatCard label="筛选后已结算" value={data.filtered_settled} hint="应用筛选条件后" />
<StatCard label="实际评估" value={data.evaluated} hint="status=success 且比分齐全" />
<StatCard label="跳过 degraded" value={data.skipped_degraded} hint="不计入准确率" />
</div>
)}
{/* 准确率表格 */}
<Card>
<CardHeader
title="准确率对比"
description="按 provider × 模型聚合,仅统计有效预测"
/>
<CardBody>
{loading ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
) : summary.length === 0 ? (
<EmptyText text="暂无评估数据,请调整筛选条件或先完成预测与结算" />
) : (
<DataTable
columns={[
{ key: 'provider', label: '提供商' },
{ key: 'model', label: '模型' },
{ key: 'total', label: '评估条数' },
{ key: 'accuracy_1x2', label: '1X2 准确率', render: (row: any) => (
<span className="tabular-nums">{row.accuracy_1x2 != null ? `${row.accuracy_1x2}%` : '—'}</span>
) },
{ key: 'avg_score_rmse', label: '比分 RMSE', render: (row: any) => (
<span className="tabular-nums">{row.avg_score_rmse != null ? row.avg_score_rmse.toFixed(2) : '—'}</span>
) },
{ key: 'avg_subjective_confidence', label: '平均置信度', render: (row: any) => (
<span className="tabular-nums">{row.avg_subjective_confidence != null ? row.avg_subjective_confidence.toFixed(2) : '—'}</span>
) },
]}
data={summary}
rowKey={(row: any) => `${row.provider}-${row.model}`}
emptyText="暂无评估数据"
/>
)}
</CardBody>
</Card>
</div>
)
}