d04d830c37
审计 P1「admin 三页纯 mock」之一。此前 EvalsPage 是写死的质量趋势+编造的 错题本+虚构纠偏轨迹。现接 sundynix_eval 真数据(评测经 JetStream eval 流持久 落库,刚升级)。 - store/eval_query.go:EvalTrend(按天 avg 综合分/忠实度+低分计数)、EvalSummaryFor (ok/warn/poor/corrected 计数+均值)、PoorEvals(错题本,level in poor/warn + 评语+纠偏标记+租户名)。全 WithoutTenant 平台口径;忠实度均值只算 sources>0 (无来源的忠实度恒0会压低失真)。 - GET /admin/evals?days=(RequireAdmin);admin api.ts + EvalsPage 重写: 总览卡片(综合分/合格率/低分占比/纠偏采纳率)+质量&忠实度趋势(纯SVG折线+低分 背景条)+错题本(点行展开评语)。 - 诚实边界:纠偏「前后全文对照」后端未持久化,只存了 Reason/Corrected/各维度分, 故错题本展示评语+「已纠偏」标记,不再编造 before/after。 live:/admin/evals 返 57 次评测 avg=0.88、错题本10条、11天趋势;浏览器渲染 真数据(趋势线07-15真实下探)。go+tsc+41 vitest 全绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
185 lines
10 KiB
TypeScript
185 lines
10 KiB
TypeScript
import { Fragment, useEffect, useState, type ReactNode } from "react";
|
||
import { adminEvals, type EvalDay, type EvalSummary, type PoorEval } from "../api";
|
||
|
||
// 自动评测观测:真数据(来自 sundynix_eval,评测经 JetStream eval 流持久落库)。
|
||
// 质量趋势 + 计数总览 + 错题本(低分评测 + 评语 + 纠偏标记)。
|
||
// 注:纠偏前后全文轨迹后端未持久化,错题本展示评语(Reason)与「已纠偏」标记,不含 before/after 对照。
|
||
const pct = (v: number) => `${Math.round(v * 100)}%`;
|
||
const LEVEL_BADGE: Record<string, string> = { poor: "bg-rose-50 text-rose-600", warn: "bg-amber-50 text-amber-600", ok: "bg-emerald-50 text-emerald-600" };
|
||
const LEVEL_LABEL: Record<string, string> = { poor: "低分", warn: "警告", ok: "合格" };
|
||
const mmdd = (ymd: string) => (ymd.length === 8 ? `${ymd.slice(4, 6)}-${ymd.slice(6, 8)}` : ymd);
|
||
|
||
export function EvalsPage() {
|
||
const [days, setDays] = useState(14);
|
||
const [trend, setTrend] = useState<EvalDay[]>([]);
|
||
const [summary, setSummary] = useState<EvalSummary | null>(null);
|
||
const [poor, setPoor] = useState<PoorEval[]>([]);
|
||
const [open, setOpen] = useState<string | null>(null); // 展开评语的 task_id
|
||
const [loading, setLoading] = useState(true);
|
||
const [err, setErr] = useState("");
|
||
|
||
useEffect(() => {
|
||
setLoading(true);
|
||
adminEvals(days)
|
||
.then((r) => {
|
||
setTrend(r.trend);
|
||
setSummary(r.summary);
|
||
setPoor(r.poor);
|
||
setErr("");
|
||
})
|
||
.catch((e) => setErr((e as Error).message))
|
||
.finally(() => setLoading(false));
|
||
}, [days]);
|
||
|
||
if (loading) return <div className="text-sm text-gray-400">加载评测数据中…</div>;
|
||
if (err) return <div className="text-sm text-rose-500">评测加载失败:{err}</div>;
|
||
|
||
const s = summary!;
|
||
const correctRate = s.poor + s.warn > 0 ? s.corrected / (s.poor + s.warn) : 0;
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div>
|
||
<h2 className="text-base font-semibold text-gray-800">自动评测</h2>
|
||
<p className="text-xs text-gray-400">质量趋势 · 忠实度 · 低分错题本与纠偏成效(全平台真实评测)</p>
|
||
</div>
|
||
<div className="flex overflow-hidden rounded-lg border border-gray-200 text-xs">
|
||
{[7, 14, 30].map((d) => (
|
||
<button key={d} onClick={() => setDays(d)}
|
||
className={`px-3 py-1.5 ${days === d ? "bg-violet-600 text-white" : "bg-white text-gray-500 hover:bg-gray-50"}`}>
|
||
近 {d} 天
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 总览计数 */}
|
||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||
<Stat label="综合质量分" value={pct(s.avg_overall)} tone="violet" sub={`${s.total} 次评测`} />
|
||
<Stat label="合格率" value={s.total ? pct(s.ok / s.total) : "—"} tone="emerald" sub={`${s.ok} 合格 · ${s.warn} 警告 · ${s.poor} 低分`} />
|
||
<Stat label="低分占比" value={s.total ? pct(s.poor / s.total) : "—"} tone="rose" sub="幻觉/规则违背/低质" />
|
||
<Stat label="纠偏采纳率" value={s.poor + s.warn ? pct(correctRate) : "—"} tone="cyan" sub={`${s.corrected} 次自动纠偏被采纳`} />
|
||
</div>
|
||
|
||
{/* 质量 & 忠实度趋势 */}
|
||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||
<div className="mb-4 flex items-center justify-between">
|
||
<h4 className="text-sm font-semibold text-gray-700">质量与忠实度趋势</h4>
|
||
<div className="flex gap-4 text-[11px]">
|
||
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-violet-500" />综合分</span>
|
||
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-cyan-500" />忠实度</span>
|
||
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-rose-300" />低分数</span>
|
||
</div>
|
||
</div>
|
||
<TrendChart trend={trend} />
|
||
</div>
|
||
|
||
{/* 错题本 */}
|
||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<h4 className="text-sm font-semibold text-gray-700">错题本(低分/警告评测)</h4>
|
||
<span className="text-[11px] text-gray-400">点行展开评语;「已纠偏」= 自动重生成后采纳</span>
|
||
</div>
|
||
{poor.length === 0 ? (
|
||
<div className="py-8 text-center text-xs text-gray-400">区间内没有低分评测 —— 质量良好</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||
<th className="py-2 pr-3 font-medium">时间</th>
|
||
<th className="py-2 pr-3 font-medium">任务 / 租户</th>
|
||
<th className="py-2 pr-3 text-right font-medium">综合</th>
|
||
<th className="py-2 pr-3 text-right font-medium">规则</th>
|
||
<th className="py-2 pr-3 text-right font-medium">质量</th>
|
||
<th className="py-2 pr-3 text-right font-medium">忠实</th>
|
||
<th className="py-2 font-medium">等级</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{poor.map((r) => (
|
||
<Fragment key={r.task_id}>
|
||
<tr onClick={() => setOpen(open === r.task_id ? null : r.task_id)}
|
||
className="cursor-pointer border-b border-gray-50 hover:bg-gray-50">
|
||
<td className="py-2 pr-3 text-xs text-gray-500">{r.created_at}</td>
|
||
<td className="py-2 pr-3">
|
||
<div className="font-mono text-[11px] text-gray-600">{r.task_id}</div>
|
||
<div className="text-[11px] text-gray-400">{r.tenant_name || "—"}</div>
|
||
</td>
|
||
<td className="py-2 pr-3 text-right tabular-nums text-gray-800">{pct(r.overall)}</td>
|
||
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{pct(r.rule)}</td>
|
||
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{pct(r.llm)}</td>
|
||
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{r.sources > 0 ? pct(r.faithful) : "—"}</td>
|
||
<td className="py-2">
|
||
<div className="flex items-center gap-1">
|
||
<span className={`rounded px-1.5 py-0.5 text-[10px] ${LEVEL_BADGE[r.level] ?? "bg-gray-100 text-gray-500"}`}>{LEVEL_LABEL[r.level] ?? r.level}</span>
|
||
{r.corrected && <span className="rounded bg-cyan-50 px-1.5 py-0.5 text-[10px] text-cyan-600">已纠偏</span>}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{open === r.task_id && (
|
||
<tr className="bg-gray-50/60">
|
||
<td colSpan={7} className="px-3 py-3">
|
||
<div className="text-[11px] font-medium text-gray-500">评测评语</div>
|
||
<p className="mt-1 whitespace-pre-wrap text-xs leading-relaxed text-gray-700">{r.reason || "(无评语)"}</p>
|
||
<div className="mt-2 text-[11px] text-gray-400">检索来源 {r.sources} 处 · 提交者 {r.owner || "—"}</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</Fragment>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const TONE: Record<string, string> = { violet: "text-violet-600", emerald: "text-emerald-600", rose: "text-rose-500", cyan: "text-cyan-600" };
|
||
function Stat({ label, value, sub, tone }: { label: string; value: ReactNode; sub?: ReactNode; tone: string }) {
|
||
return (
|
||
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
|
||
<div className="text-xs text-gray-400">{label}</div>
|
||
<div className={`mt-1 text-2xl font-semibold tabular-nums ${TONE[tone] ?? "text-gray-800"}`}>{value}</div>
|
||
{sub && <div className="mt-1 text-[11px] text-gray-400">{sub}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// TrendChart:综合分/忠实度折线(左轴 [0,1])+ 低分条数背景条。纯 SVG,无依赖。
|
||
function TrendChart({ trend }: { trend: EvalDay[] }) {
|
||
if (trend.length === 0) return <div className="py-8 text-center text-xs text-gray-400">区间内暂无评测</div>;
|
||
const w = 720, h = 160, pad = 24;
|
||
const n = trend.length;
|
||
const x = (i: number) => pad + (n === 1 ? (w - 2 * pad) / 2 : (i * (w - 2 * pad)) / (n - 1));
|
||
const y = (v: number) => h - pad - v * (h - 2 * pad);
|
||
const maxPoor = Math.max(1, ...trend.map((d) => d.poor_count));
|
||
const line = (get: (d: EvalDay) => number) => trend.map((d, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(get(d)).toFixed(1)}`).join(" ");
|
||
|
||
return (
|
||
<div className="overflow-x-auto">
|
||
<svg viewBox={`0 0 ${w} ${h + 20}`} className="w-full" style={{ minWidth: 480 }}>
|
||
{[0, 0.5, 1].map((g) => (
|
||
<g key={g}>
|
||
<line x1={pad} y1={y(g)} x2={w - pad} y2={y(g)} stroke="#f1f1f4" />
|
||
<text x={4} y={y(g) + 3} fontSize="9" fill="#bbb">{g}</text>
|
||
</g>
|
||
))}
|
||
{/* 低分条数背景条 */}
|
||
{trend.map((d, i) => (
|
||
<rect key={i} x={x(i) - 6} y={h - pad - (d.poor_count / maxPoor) * (h - 2 * pad) * 0.5} width={12}
|
||
height={(d.poor_count / maxPoor) * (h - 2 * pad) * 0.5} fill="#fecdd3" opacity={0.6} rx={2} />
|
||
))}
|
||
<path d={line((d) => d.avg_overall)} fill="none" stroke="#7c3aed" strokeWidth={2} />
|
||
<path d={line((d) => d.avg_faithful || 0)} fill="none" stroke="#06b6d4" strokeWidth={2} strokeDasharray="3 2" />
|
||
{trend.map((d, i) => (
|
||
<text key={i} x={x(i)} y={h + 12} fontSize="9" fill="#999" textAnchor="middle">{mmdd(d.day)}</text>
|
||
))}
|
||
</svg>
|
||
</div>
|
||
);
|
||
}
|