Files
sundynix-agentix/sundynix-admin/src/pages/EvalsPage.tsx
T
Blizzard d04d830c37 feat(admin): 「自动评测」页做实 —— 接真评测数据,去 mock (P1)
审计 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>
2026-07-18 12:03:54 +08:00

185 lines
10 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.
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>
);
}