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>
This commit is contained in:
@@ -301,6 +301,50 @@ export async function adminReconcile(): Promise<{ diffs: ReconcileDiff[]; ok: bo
|
||||
return { diffs: d.diffs ?? [], ok: !!d.ok };
|
||||
}
|
||||
|
||||
// ---- 自动评测观测(真数据,来自 sundynix_eval)----
|
||||
export interface EvalDay {
|
||||
day: string; // YYYYMMDD
|
||||
avg_overall: number;
|
||||
avg_faithful: number;
|
||||
count: number;
|
||||
poor_count: number;
|
||||
}
|
||||
export interface EvalSummary {
|
||||
total: number;
|
||||
ok: number;
|
||||
warn: number;
|
||||
poor: number;
|
||||
corrected: number;
|
||||
avg_overall: number;
|
||||
}
|
||||
export interface PoorEval {
|
||||
task_id: string;
|
||||
tenant_name: string;
|
||||
owner: string;
|
||||
overall: number;
|
||||
rule: number;
|
||||
llm: number;
|
||||
faithful: number;
|
||||
level: string;
|
||||
reason: string;
|
||||
sources: number;
|
||||
corrected: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function adminEvals(days = 14): Promise<{ from: string; to: string; trend: EvalDay[]; summary: EvalSummary; poor: PoorEval[] }> {
|
||||
const res = guard(await fetch(`${ADMIN}/evals?days=${days}`, { headers: authHeaders() }));
|
||||
const d = (await res.json().catch(() => ({}))) as { from?: string; to?: string; trend?: EvalDay[]; summary?: EvalSummary; poor?: PoorEval[]; error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `evals failed: ${res.status}`);
|
||||
return {
|
||||
from: d.from ?? "",
|
||||
to: d.to ?? "",
|
||||
trend: d.trend ?? [],
|
||||
summary: d.summary ?? { total: 0, ok: 0, warn: 0, poor: 0, corrected: 0, avg_overall: 0 },
|
||||
poor: d.poor ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
// gatewayOnline 用公开的 /healthz 探活(不受鉴权影响)。
|
||||
export async function gatewayOnline(): Promise<boolean> {
|
||||
try {
|
||||
|
||||
@@ -1,258 +1,184 @@
|
||||
import { useState } from "react";
|
||||
import { Fragment, useEffect, useState, type ReactNode } from "react";
|
||||
import { adminEvals, type EvalDay, type EvalSummary, type PoorEval } from "../api";
|
||||
|
||||
// Mock 评测趋势数据
|
||||
const QUALITY_TREND = [0.82, 0.84, 0.79, 0.81, 0.85, 0.88, 0.89, 0.87, 0.86, 0.91, 0.92, 0.88, 0.87, 0.88];
|
||||
const HALLUCINATION_TREND = [18, 15, 22, 19, 14, 11, 10, 12, 13, 8, 7, 11, 12, 10]; // % 比例
|
||||
const DATES = ["06-14", "06-15", "06-16", "06-17", "06-18", "06-19", "06-20", "06-21", "06-22", "06-23", "06-24", "06-25", "06-26", "06-27"];
|
||||
|
||||
// Mock 低分评测记录(错题本)与纠偏轨迹
|
||||
const MOCK_POOR_RUNS = [
|
||||
{
|
||||
id: "task_e8f2a1b9",
|
||||
time: "11:47:05",
|
||||
user: "Bob",
|
||||
agentName: "法律合同审查 Agent",
|
||||
overall: 0.42,
|
||||
ruleScore: 0.60,
|
||||
llmScore: 0.50,
|
||||
faithful: 0.15,
|
||||
level: "poor",
|
||||
reason: "幻觉严重。模型声称合同中包含‘三年内无条件退款限制条款’,但所附 RAG 参考材料中仅提及‘按比例折算退款规则’,属于严重的知识库脱轨和无中生有(无立足依据)。",
|
||||
trace: {
|
||||
initialAnswer: "根据合同第 4 条,本合同包含三年内无条件全额退款条款,客户可随时申请解除合作。",
|
||||
critique: "【评测发现异常】RAG 知识块[文档: 退款政策_2026.pdf]中明确规定退款须‘按合作月份比例折算,扣除已产生渠道服务费后退还余款,且需提前30天书面申请’。模型回答‘无条件全额退款’属严重事实性捏造,忠实度(Faithful)分值判定为 0.15。",
|
||||
refinePrompt: "你是一个严肃的合同审查纠偏助手。在前一次生成中,模型产生了事实性幻觉。请根据参考材料【退款政策_2026.pdf】:‘退款须按合作月份比例折算,扣除已产生渠道服务费后退还余款,且需提前30天书面申请’,对前次答案【根据合同第 4 条...】进行修改纠正,必须忠实于材料,杜绝捏造无条件条款。",
|
||||
refinedAnswer: "根据退款政策附件规则,退款非无条件全额,而是必须按合作月份比例折算,且扣除已产生渠道服务费后退还余款。此外,客户申请退款需提前30天提交书面申请,合同第4条仅规定了申请路径,而非‘无条件退款’。",
|
||||
newScore: 0.85
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "task_ff3c0b12",
|
||||
time: "10:12:30",
|
||||
user: "Alice",
|
||||
agentName: "医学文献总结 Agent",
|
||||
overall: 0.55,
|
||||
ruleScore: 0.50,
|
||||
llmScore: 0.60,
|
||||
faithful: 0.55,
|
||||
level: "poor",
|
||||
reason: "引用缺失。用户要求‘回答时必须在事实断言处标注 RAG 参考的文档来源’,但模型生成的总结文本中未包含任何形如 [1] 或 [doc_xxx] 的引用锚点,违反了输入 DSL 的强制规范规则。",
|
||||
trace: {
|
||||
initialAnswer: "该临床研究表明,使用该抗体偶联药物能提高 15% 的无进展生存期(PFS),且中位缓解期达到了 12.4 个月。",
|
||||
critique: "【评测发现异常】模型得出了准确的结论,但未按照强制指令将数据来源指向知识块 [文献_ADC研究_Phase3.pdf]。违反强制引用约束,规则分降低为 0.50。",
|
||||
refinePrompt: "前次生成中未包含数据来源引用。请在前次总结中,为‘提高 15% 的无进展生存期’与‘中位缓解期达到 12.4 个月’数据,在行内添加形如 [文献_ADC研究_Phase3.pdf] 的数据来源引用标签。",
|
||||
refinedAnswer: "最新临床三期研究表明,使用该抗体偶联药物能提高 15% 的无进展生存期(PFS)[文献_ADC研究_Phase3.pdf],且中位缓解期达到了 12.4 个月[文献_ADC研究_Phase3.pdf]。",
|
||||
newScore: 0.92
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "task_a5fd2510",
|
||||
time: "09:30:15",
|
||||
user: "Alice",
|
||||
agentName: "财务审计核对 Agent",
|
||||
overall: 0.48,
|
||||
ruleScore: 0.40,
|
||||
llmScore: 0.55,
|
||||
faithful: 0.50,
|
||||
level: "poor",
|
||||
reason: "计算口径不一致。知识库给出的 2025 Q4 营业收入为‘1.2 亿元(含未结算款项)’,但财务报表核查中模型漏计了未结算款,算出了 1.05 亿元,与账面发生额不符。",
|
||||
trace: {
|
||||
initialAnswer: "核对完毕,Q4 累计核算收入为 1.05 亿元,与系统申报一致。",
|
||||
critique: "【评测发现异常】少计算了未结算款项 1500 万元。知识库规定必须将未结算款项计入 Q4 营业收入内。规则评分降为 0.40。",
|
||||
refinePrompt: "计算遗漏。请把财务参考中的‘1500万未结算款项’加进 Q4 营业收入核对中,重新计算总营收并核对金额。",
|
||||
refinedAnswer: "重新核算后,Q4 累计核算总收入为 1.2 亿元(已包含 1500 万元未结算款项),与系统申报发生额完全吻合。",
|
||||
newScore: 0.89
|
||||
}
|
||||
}
|
||||
];
|
||||
// 自动评测观测:真数据(来自 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 [runs, setRuns] = useState(MOCK_POOR_RUNS);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
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("");
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
setExpandedId((prev) => (prev === id ? null : id));
|
||||
};
|
||||
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]);
|
||||
|
||||
// SVG 趋势图宽高
|
||||
const chartW = 260;
|
||||
const chartH = 70;
|
||||
const pad = 10;
|
||||
if (loading) return <div className="text-sm text-gray-400">加载评测数据中…</div>;
|
||||
if (err) return <div className="text-sm text-rose-500">评测加载失败:{err}</div>;
|
||||
|
||||
// 1. 质量曲线点计算
|
||||
const maxValQ = 1.0;
|
||||
const pointsQ = QUALITY_TREND.map((val, idx) => {
|
||||
const x = pad + (idx * (chartW - pad * 2)) / (QUALITY_TREND.length - 1);
|
||||
const y = chartH - pad - (val * (chartH - pad * 2)) / maxValQ;
|
||||
return { x, y };
|
||||
});
|
||||
const pathQ = pointsQ.reduce((p, pt, i) => p + `${i === 0 ? "M" : "L"} ${pt.x.toFixed(1)} ${pt.y.toFixed(1)}`, "");
|
||||
|
||||
// 2. 幻觉率曲线点计算
|
||||
const maxValH = 30; // 最大 30% 刻度
|
||||
const pointsH = HALLUCINATION_TREND.map((val, idx) => {
|
||||
const x = pad + (idx * (chartW - pad * 2)) / (HALLUCINATION_TREND.length - 1);
|
||||
const y = chartH - pad - (val * (chartH - pad * 2)) / maxValH;
|
||||
return { x, y };
|
||||
});
|
||||
const pathH = pointsH.reduce((p, pt, i) => p + `${i === 0 ? "M" : "L"} ${pt.x.toFixed(1)} ${pt.y.toFixed(1)}`, "");
|
||||
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="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{/* 指标 1:综合评测均分 */}
|
||||
<section className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-400">自动化综合均分</span>
|
||||
<h3 className="mt-1 text-2xl font-bold text-gray-800">0.88</h3>
|
||||
<span className="text-[10px] text-emerald-600">本周均值较上周 ↑ 3%</span>
|
||||
</div>
|
||||
{/* 微型折线图 */}
|
||||
<div className="w-36 h-12 bg-gray-50/50 rounded border p-1">
|
||||
<svg viewBox={`0 0 ${chartW} ${chartH}`} className="w-full h-full overflow-visible">
|
||||
<path d={pathQ} fill="none" stroke="#7c3aed" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 指标 2:忠实度评测与幻觉率 */}
|
||||
<section className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-400">平均幻觉发生率</span>
|
||||
<h3 className="mt-1 text-2xl font-bold text-rose-600">10%</h3>
|
||||
<span className="text-[10px] text-emerald-600">较本月初降低 ↓ 8%</span>
|
||||
</div>
|
||||
{/* 微型折线图 */}
|
||||
<div className="w-36 h-12 bg-gray-50/50 rounded border p-1">
|
||||
<svg viewBox={`0 0 ${chartW} ${chartH}`} className="w-full h-full overflow-visible">
|
||||
<path d={pathH} fill="none" stroke="#f43f5e" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 指标 3:纠偏系统效能 */}
|
||||
<section className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
|
||||
<span className="text-xs font-medium text-gray-400">低分纠偏成功率 (恒温器效能)</span>
|
||||
<div className="mt-2 flex items-baseline gap-2">
|
||||
<h3 className="text-2xl font-bold text-gray-800">84.2%</h3>
|
||||
<span className="text-xs text-gray-500">纠偏重生成共计 228 次</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 w-full bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-violet-600 rounded-full" style={{ width: "84.2%" }} />
|
||||
</div>
|
||||
</section>
|
||||
<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>
|
||||
|
||||
{/* 自动纠偏错题本(失败记录) */}
|
||||
<section className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700">自动纠偏错题本 (质量异常事件)</h3>
|
||||
<p className="text-[11px] text-gray-400">罗列所有触发低分(poor)警告的请求,可点击展开查看系统对答案的自动纠正(Refinement)全轨迹</p>
|
||||
{/* 总览计数 */}
|
||||
<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="space-y-3">
|
||||
{runs.map((r) => {
|
||||
const isExpanded = expandedId === r.id;
|
||||
return (
|
||||
<div key={r.id} className="rounded-lg border border-gray-100 overflow-hidden">
|
||||
{/* 简要行 */}
|
||||
<div
|
||||
onClick={() => toggleExpand(r.id)}
|
||||
className={`flex flex-col md:flex-row md:items-center justify-between gap-4 p-3.5 cursor-pointer hover:bg-gray-50/50 transition-colors ${
|
||||
isExpanded ? "bg-violet-50/20 border-b border-violet-100" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-mono font-bold text-gray-400">[{r.time}]</span>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-800">{r.agentName}</div>
|
||||
<div className="text-[10px] text-gray-400">提交者: {r.user} | ID: {r.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分数指标组 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-center">
|
||||
<div className="text-[9px] text-gray-400">综合得分</div>
|
||||
<div className="text-xs font-bold text-rose-600">{r.overall.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="text-center border-l pl-3">
|
||||
<div className="text-[9px] text-gray-400">规则约束</div>
|
||||
<div className="text-xs font-semibold text-gray-600">{r.ruleScore.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="text-center border-l pl-3">
|
||||
<div className="text-[9px] text-gray-400">RAG忠实度</div>
|
||||
<div className={`text-xs font-semibold ${r.faithful <= 0.3 ? "text-rose-600" : "text-gray-600"}`}>
|
||||
{r.faithful.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center border-l pl-3">
|
||||
<div className="text-[9px] text-gray-400">纠偏后分数</div>
|
||||
<div className="text-xs font-bold text-emerald-600">↑ {r.trace.newScore.toFixed(2)}</div>
|
||||
</div>
|
||||
|
||||
{/* 展开折叠箭头 */}
|
||||
<span className="text-gray-400 text-xs pl-2 font-mono">{isExpanded ? "▲" : "▼"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 展开详细信息(纠偏对齐轨迹详情) */}
|
||||
{isExpanded && (
|
||||
<div className="p-4 bg-gray-50/30 text-xs space-y-4 animate-fadeIn">
|
||||
{/* 1. 问题定位 */}
|
||||
<div className="border-l-2 border-rose-500 pl-3">
|
||||
<h5 className="font-bold text-gray-800">评测问题诊断 (Evaluator Diagnosis)</h5>
|
||||
<p className="mt-1 text-gray-600 leading-snug">{r.reason}</p>
|
||||
</div>
|
||||
|
||||
{/* 2. 纠偏流转卡片组 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 初次答案 */}
|
||||
<div className="rounded border bg-white p-3">
|
||||
<div className="text-[10px] font-bold text-rose-600 flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-rose-500" />
|
||||
初次生成(存在幻觉/缺陷) Score: {r.overall.toFixed(2)}
|
||||
</div>
|
||||
<p className="mt-2 text-gray-500 font-mono leading-relaxed">{r.trace.initialAnswer}</p>
|
||||
</div>
|
||||
|
||||
{/* 初次评测评语 */}
|
||||
<div className="rounded border bg-white p-3">
|
||||
<div className="text-[10px] font-bold text-amber-600 flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
|
||||
评测诊断意见 (Evaluator Critique)
|
||||
</div>
|
||||
<p className="mt-2 text-gray-500 leading-relaxed">{r.trace.critique}</p>
|
||||
</div>
|
||||
|
||||
{/* 纠偏 Prompt 注入 */}
|
||||
<div className="rounded border bg-white p-3 md:col-span-2">
|
||||
<div className="text-[10px] font-bold text-violet-600 flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-violet-500 animate-pulse" />
|
||||
纠偏重写指令 (Refinement Rewrite Prompt)
|
||||
</div>
|
||||
<p className="mt-2 text-gray-500 font-mono leading-relaxed bg-gray-50 p-2 rounded border border-gray-100">
|
||||
{r.trace.refinePrompt}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 纠偏后最终答案 */}
|
||||
<div className="rounded border bg-white p-3 md:col-span-2 border-emerald-200 bg-emerald-50/10">
|
||||
<div className="text-[10px] font-bold text-emerald-600 flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
纠偏重写采纳(纠偏成功) Score: {r.trace.newScore.toFixed(2)}
|
||||
</div>
|
||||
<p className="mt-2 text-gray-700 font-mono leading-relaxed">{r.trace.refinedAnswer}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</section>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -570,3 +570,23 @@ func (h *Handler) AdminUsage(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// AdminEvals: GET /api/v1/admin/evals?days= —— 自动评测观测(趋势 + 计数 + 错题本)。全平台口径。
|
||||
// 数据来自 sundynix_eval(评测经 JetStream eval 流持久落库);此前该页纯 mock。
|
||||
func (h *Handler) AdminEvals(c *gin.Context) {
|
||||
ctx := store.WithoutTenant(c.Request.Context())
|
||||
now := time.Now()
|
||||
days := 14
|
||||
if d, err := strconv.Atoi(c.Query("days")); err == nil && d > 0 && d <= 90 {
|
||||
days = d
|
||||
}
|
||||
from := now.AddDate(0, 0, -(days - 1)).Format("20060102")
|
||||
to := now.Format("20060102")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"from": from,
|
||||
"to": to,
|
||||
"trend": h.db.EvalTrend(ctx, from, to),
|
||||
"summary": h.db.EvalSummaryFor(ctx, from, to),
|
||||
"poor": h.db.PoorEvals(ctx, 30),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
||||
admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册
|
||||
admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康
|
||||
admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额
|
||||
admin.GET("/evals", h.AdminEvals) // 自动评测观测:质量趋势 + 计数 + 错题本(真数据)
|
||||
admin.POST("/migrate-kb-storage", h.MigrateKBStorage) // 增量3:存量 KB 三库 owner/kb→space/kb 重灌(一次性)
|
||||
admin.GET("/audit", h.AuditList) // 敏感操作审计流(倒序,翻页)
|
||||
admin.GET("/guardrail-events", h.GuardrailEvents) // 护栏命中安全事件流(倒序,翻页)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package store
|
||||
|
||||
import "context"
|
||||
|
||||
// 评测观测查询(admin「自动评测」页做实用;此前该页纯 mock)。全平台口径 → WithoutTenant。
|
||||
// 注:评测的「纠偏前后全文轨迹」后端未持久化,只存了 Reason(评语)/Corrected(是否已纠偏采纳)/
|
||||
// 各维度分,故错题本展示这些真数据,不含编造的 before/after 对照。
|
||||
|
||||
// EvalDay 是评测趋势按天一行。
|
||||
type EvalDay struct {
|
||||
Day string `json:"day"` // YYYYMMDD
|
||||
AvgOverall float64 `json:"avg_overall"` // 当日综合分均值 [0,1]
|
||||
AvgFaithful float64 `json:"avg_faithful"` // 当日忠实度均值(仅计有来源的评测)
|
||||
Count int64 `json:"count"`
|
||||
PoorCount int64 `json:"poor_count"` // 当日 poor 级条数(幻觉/低质趋势)
|
||||
}
|
||||
|
||||
// EvalTrend 按天聚合评测(from/to 为 YYYYMMDD)。avg_faithful 只算有检索来源的评测(sources>0),
|
||||
// 无来源的忠实度恒 0 会把均值压低失真。
|
||||
func (p *Postgres) EvalTrend(ctx context.Context, from, to string) []EvalDay {
|
||||
if p.db == nil {
|
||||
return nil
|
||||
}
|
||||
var out []EvalDay
|
||||
p.db.WithContext(ctx).Model(&Eval{}).
|
||||
Select("to_char(created_at,'YYYYMMDD') as day, "+
|
||||
"avg(overall) as avg_overall, "+
|
||||
"avg(case when sources > 0 then faithful end) as avg_faithful, "+
|
||||
"count(*) as count, "+
|
||||
"count(case when level = 'poor' then 1 end) as poor_count").
|
||||
Where("to_char(created_at,'YYYYMMDD') >= ? AND to_char(created_at,'YYYYMMDD') <= ?", from, to).
|
||||
Group("day").Order("day").Scan(&out)
|
||||
return out
|
||||
}
|
||||
|
||||
// EvalSummary 是评测总览计数。
|
||||
type EvalSummary struct {
|
||||
Total int64 `json:"total"`
|
||||
OK int64 `json:"ok"`
|
||||
Warn int64 `json:"warn"`
|
||||
Poor int64 `json:"poor"`
|
||||
Corrected int64 `json:"corrected"` // 经低分自动纠偏重生成后采纳的条数(恒温器闭环成效)
|
||||
AvgOverall float64 `json:"avg_overall"` // 区间综合分均值
|
||||
}
|
||||
|
||||
// EvalSummaryFor 区间内评测计数(from/to 为 YYYYMMDD)。
|
||||
func (p *Postgres) EvalSummaryFor(ctx context.Context, from, to string) EvalSummary {
|
||||
var s EvalSummary
|
||||
if p.db == nil {
|
||||
return s
|
||||
}
|
||||
p.db.WithContext(ctx).Model(&Eval{}).
|
||||
Select("count(*) as total, "+
|
||||
"count(case when level='ok' then 1 end) as ok, "+
|
||||
"count(case when level='warn' then 1 end) as warn, "+
|
||||
"count(case when level='poor' then 1 end) as poor, "+
|
||||
"count(case when corrected then 1 end) as corrected, "+
|
||||
"coalesce(avg(overall),0) as avg_overall").
|
||||
Where("to_char(created_at,'YYYYMMDD') >= ? AND to_char(created_at,'YYYYMMDD') <= ?", from, to).
|
||||
Scan(&s)
|
||||
return s
|
||||
}
|
||||
|
||||
// PoorEval 是错题本一行(低分评测 + 评语 + 纠偏标记;带租户名免前端二次查)。
|
||||
type PoorEval struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TenantName string `json:"tenant_name"`
|
||||
Owner string `json:"owner"`
|
||||
Overall float64 `json:"overall"`
|
||||
Rule float64 `json:"rule"`
|
||||
LLM float64 `json:"llm"`
|
||||
Faithful float64 `json:"faithful"`
|
||||
Level string `json:"level"`
|
||||
Reason string `json:"reason"`
|
||||
Sources int `json:"sources"`
|
||||
Corrected bool `json:"corrected"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// PoorEvals 最近的低分评测(level=poor/warn,错题本)。
|
||||
func (p *Postgres) PoorEvals(ctx context.Context, limit int) []PoorEval {
|
||||
if p.db == nil {
|
||||
return nil
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 30
|
||||
}
|
||||
var out []PoorEval
|
||||
p.db.WithContext(ctx).Table("sundynix_eval e").
|
||||
Select("e.task_id, coalesce(t.name,'') as tenant_name, e.owner, e.overall, e.rule, e.llm, "+
|
||||
"e.faithful, e.level, e.reason, e.sources, e.corrected, "+
|
||||
"to_char(e.created_at,'YYYY-MM-DD HH24:MI') as created_at").
|
||||
Joins("left join sundynix_tenant t on t.id = e.tenant_id").
|
||||
Where("e.level in ('poor','warn') AND e.deleted_at IS NULL").
|
||||
Order("e.created_at desc").Limit(limit).Scan(&out)
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user