完善评估能力:筛选参数 + 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 排除准确率
This commit is contained in:
Profeto Agent
2026-09-19 09:40:24 +00:00
parent c2c4752856
commit 835d7217d0
21 changed files with 888 additions and 67 deletions
+192
View File
@@ -0,0 +1,192 @@
/**
* 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>
)
}