feat(admin): 「数据源 & RAG」页做实 —— admin 三 mock 页清零 (P1)

审计 P1「admin 三页纯 mock」最后一页。此前 DatasourcesPage 的 GraphRAG 拓扑图
写死节点、「向量/全文/图谱权重滑块」纯 mock——而且权重概念本身虚构:mcp-go 的
RRF 融合是各路等权的倒排互惠融合(rrfK=60 平滑常数),根本没有"每路占几成"。

- store/datasource_query.go:AllDatasources(全平台 KB + 各库文档数/总字数,
  按 (space_id,name) 关联 doc,WithoutTenant);GET /admin/datasources(含
  SystemCounts 平台计数)。
- DatasourcesPage 重写:保留真实 Embedding 模型配置(ModelManager) + 诚实的
  混合检索管线说明(三路 Milvus/Bleve/Neo4j + RRF 等权融合 k=60,非可调权重) +
  平台计数卡片 + 真知识库清单表。删假滑块+假拓扑(~200行 mock)。

诚实边界:RRF 无每路权重,不摆假滑块;检索参数在 mcp-go 代码中。

live:/admin/datasources 返 34用户/25库/53文档,清单带真实文档数字数;
浏览器渲染全对。tsc+41 vitest 全绿。**admin 三 mock 页(Evals/Guardrails/
Datasources)全部做实。**

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-18 12:26:16 +08:00
parent c02ebc7bce
commit e29bc9a91e
5 changed files with 173 additions and 222 deletions
+19
View File
@@ -365,6 +365,25 @@ export async function guardrailEvents(limit = 100): Promise<GuardrailEvent[]> {
return d.events ?? []; return d.events ?? [];
} }
// ---- 数据源清单(真数据,全平台知识库)----
export interface DatasourceKB {
id: string;
name: string;
kind: string;
tenant_id: string;
tenant_name: string;
owner: string;
doc_count: number;
total_words: number;
}
export async function adminDatasources(): Promise<{ counts: { users: number; kbs: number; docs: number }; datasources: DatasourceKB[] }> {
const res = guard(await fetch(`${ADMIN}/datasources`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { counts?: { users: number; kbs: number; docs: number }; datasources?: DatasourceKB[]; error?: string };
if (!res.ok) throw new Error(d.error ?? `datasources failed: ${res.status}`);
return { counts: d.counts ?? { users: 0, kbs: 0, docs: 0 }, datasources: d.datasources ?? [] };
}
// gatewayOnline 用公开的 /healthz 探活(不受鉴权影响)。 // gatewayOnline 用公开的 /healthz 探活(不受鉴权影响)。
export async function gatewayOnline(): Promise<boolean> { export async function gatewayOnline(): Promise<boolean> {
try { try {
+103 -218
View File
@@ -1,69 +1,37 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { ModelManager } from "../components/ModelManager"; import { ModelManager } from "../components/ModelManager";
import { adminDatasources, type DatasourceKB } from "../api";
// Mock GraphRAG 拓扑节点 // 数据源 & RAG:真数据(全平台知识库清单,来自 sundynix_kb/sundynix_doc)。
const INITIAL_NODES = [ // 此前该页 GraphRAG 拓扑图与「向量/全文/图谱权重滑块」全 mock——而且权重概念本身是虚构的:
{ id: "node_1", x: 180, y: 50, label: "Beta Tech", type: "tenant", desc: "租户空间组织实体" }, // mcp-go 的 RRF 融合是各路等权的倒排互惠融合(rrfK=60 平滑常数),没有"每路占几成"的权重。
{ id: "node_2", x: 80, y: 120, label: "知识库: 退款政策_2026.pdf", type: "document", desc: "主退款规则文档,字数: 3,420" }, // 故删假滑块+假拓扑,换成真数据源清单 + 诚实的检索管线说明。
{ id: "node_3", x: 280, y: 120, label: "知识库: 服务协议_v3.docx", type: "document", desc: "标准渠道协议模板" },
{ id: "node_4", x: 80, y: 220, label: "实体: 渠道退费限制", type: "concept", desc: "退款政策第 4 条:限制 30 天内申请" },
{ id: "node_5", x: 180, y: 220, label: "实体: 退款折算比率", type: "concept", desc: "公式:按合作月份比例计算退费" },
{ id: "node_6", x: 280, y: 220, label: "实体: 30天提前申请", type: "concept", desc: "退款前提:须有书面正式通知" }
];
// Mock GraphRAG 拓扑边关系 const KIND_LABEL: Record<string, string> = { general: "通用", folder: "文件夹", project: "项目", case: "案例" };
const EDGES = [ const fmtWords = (n: number) => (n >= 1e4 ? `${(n / 1e4).toFixed(1)}` : `${n}`);
{ from: "node_1", to: "node_2", label: "contains" },
{ from: "node_1", to: "node_3", label: "contains" },
{ from: "node_2", to: "node_4", label: "rules" },
{ from: "node_2", to: "node_5", label: "defines" },
{ from: "node_2", to: "node_6", label: "requires" },
{ from: "node_4", to: "node_6", label: "aligns" }
];
const NODE_COLORS: Record<string, string> = {
tenant: "#7c3aed", // 紫罗兰 (Violet)
document: "#06b6d4", // 青色 (Cyan)
concept: "#10b981" // 翠绿 (Emerald)
};
export function DatasourcesPage() { export function DatasourcesPage() {
const [weights, setWeights] = useState({ const [counts, setCounts] = useState({ users: 0, kbs: 0, docs: 0 });
vector: 45, const [rows, setRows] = useState<DatasourceKB[]>([]);
fullText: 35, const [loading, setLoading] = useState(true);
graph: 20 const [err, setErr] = useState("");
});
const [searchQuery, setSearchQuery] = useState(""); useEffect(() => {
const [selectedNode, setSelectedNode] = useState<typeof INITIAL_NODES[0] | null>(null); adminDatasources()
.then((r) => {
setCounts(r.counts);
setRows(r.datasources);
setErr("");
})
.catch((e) => setErr((e as Error).message))
.finally(() => setLoading(false));
}, []);
// 权重调整滑动条 const totalWords = rows.reduce((a, r) => a + r.total_words, 0);
const handleWeightChange = (key: "vector" | "fullText" | "graph", val: number) => {
setWeights((prev) => {
const next = { ...prev, [key]: val };
// 保持总和为 100% 的动态按比例计算
const diff = 100 - (next.vector + next.fullText + next.graph);
const otherKeys = (["vector", "fullText", "graph"] as const).filter((k) => k !== key);
// 平摊多余或不足的百分比
let share1 = Math.round(diff / 2);
let share2 = diff - share1;
next[otherKeys[0]] = Math.max(0, next[otherKeys[0]] + share1);
next[otherKeys[1]] = Math.max(0, next[otherKeys[1]] + share2);
return next;
});
};
// 搜索高亮节点
const filteredNodes = INITIAL_NODES.map((n) => {
const matched = searchQuery ? n.label.toLowerCase().includes(searchQuery.toLowerCase()) : false;
return { ...n, matched };
});
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* 1. Embedding 模型配置 (真实组件) */} {/* Embedding 模型配置真实组件 */}
<ModelManager <ModelManager
kind="embedding" kind="embedding"
title="Embedding 模型(embedding → mcp-go RAG" title="Embedding 模型(embedding → mcp-go RAG"
@@ -71,176 +39,93 @@ export function DatasourcesPage() {
modelHint="text-embedding-v3" modelHint="text-embedding-v3"
/> />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> {/* 检索管线说明(诚实:三路 + RRF 等权融合,非可调权重) */}
{/* 左侧 RAG 融合检索调优面板 */} <div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="space-y-6"> <h3 className="mb-3 text-sm font-semibold text-gray-700">线</h3>
<section className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm"> <div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<h3 className="text-sm font-semibold text-gray-700">RAG (RRF )</h3> <Route color="violet" name="向量检索" impl="Milvus" desc="Embedding 相似度召回语义相关块" />
<p className="text-[11px] text-gray-400 mb-4"> mcp-go (Reciprocal Rank Fusion) </p> <Route color="cyan" name="全文检索" impl="Bleve" desc="倒排索引召回关键词精确命中" />
<Route color="emerald" name="图谱检索" impl="Neo4j" desc="实体三元组召回结构化关联" />
<div className="space-y-4">
{/* 向量检索路 */}
<div>
<div className="flex justify-between text-xs text-gray-500 mb-1">
<span> (Milvus)</span>
<span className="font-semibold text-violet-600">{weights.vector}%</span>
</div> </div>
<input <p className="mt-3 text-[11px] leading-relaxed text-gray-400">
type="range" <span className="font-medium text-gray-600">RRF </span>Reciprocal Rank Fusion k=60 rerank
min="0" <code className="rounded bg-gray-100 px-1">SearchByMode</code> mcp-go
max="100" </p>
className="w-full h-1 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-violet-600"
value={weights.vector}
onChange={(e) => handleWeightChange("vector", Number(e.target.value))}
/>
</div> </div>
{/* 全文检索路 */} {/* 平台数据源计数 */}
<div> <div className="grid grid-cols-3 gap-4">
<div className="flex justify-between text-xs text-gray-500 mb-1"> <Stat label="知识库" value={String(counts.kbs)} tone="violet" />
<span> (Bleve)</span> <Stat label="文档总数" value={String(counts.docs)} tone="cyan" sub={`${fmtWords(totalWords)}`} />
<span className="font-semibold text-cyan-600">{weights.fullText}%</span> <Stat label="平台用户" value={String(counts.users)} tone="emerald" />
</div>
<input
type="range"
min="0"
max="100"
className="w-full h-1 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-cyan-500"
value={weights.fullText}
onChange={(e) => handleWeightChange("fullText", Number(e.target.value))}
/>
</div> </div>
{/* 知识图谱检索路 */} {/* 知识库清单(真数据) */}
<div> <div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="flex justify-between text-xs text-gray-500 mb-1"> <h3 className="mb-3 text-sm font-semibold text-gray-700"></h3>
<span> (Neo4j)</span> {loading ? (
<span className="font-semibold text-emerald-600">{weights.graph}%</span> <div className="py-8 text-center text-xs text-gray-400"></div>
) : err ? (
<div className="py-8 text-center text-xs text-rose-500">{err}</div>
) : rows.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 font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-b border-gray-50 last:border-0">
<td className="py-2 pr-3 font-medium text-gray-800">{r.name}</td>
<td className="py-2 pr-3"><span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600">{KIND_LABEL[r.kind] ?? r.kind}</span></td>
<td className="py-2 pr-3 text-xs text-gray-500">{r.tenant_name || r.tenant_id || "—"}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-700">{r.doc_count}</td>
<td className="py-2 text-right tabular-nums text-gray-500">{fmtWords(r.total_words)}</td>
</tr>
))}
</tbody>
</table>
</div> </div>
<input
type="range"
min="0"
max="100"
className="w-full h-1 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-emerald-500"
value={weights.graph}
onChange={(e) => handleWeightChange("graph", Number(e.target.value))}
/>
</div>
<div className="rounded bg-violet-50/50 p-2.5 border border-violet-100/50 text-[10px] text-violet-700 leading-snug">
<strong></strong>GraphRAG 100%
</div>
</div>
</section>
{/* 节点详细信息面板 */}
{selectedNode && (
<section className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm animate-fadeIn">
<div className="flex items-center gap-2 mb-2">
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: NODE_COLORS[selectedNode.type] }} />
<h4 className="text-xs font-bold text-gray-800">{selectedNode.label}</h4>
</div>
<p className="text-xs text-gray-500 mb-1">: <span className="font-semibold capitalize text-gray-600">{selectedNode.type}</span></p>
<p className="text-xs text-gray-600 leading-relaxed bg-gray-50 p-2 rounded border">{selectedNode.desc}</p>
</section>
)} )}
</div> </div>
</div>
{/* 右侧 GraphRAG 图谱拓扑可视化渲染 */} );
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm lg:col-span-2 flex flex-col"> }
<div className="mb-4 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div> const ROUTE_TONE: Record<string, { dot: string; text: string }> = {
<h3 className="text-sm font-semibold text-gray-700">GraphRAG </h3> violet: { dot: "bg-violet-500", text: "text-violet-600" },
<p className="text-[11px] text-gray-400"> LLM Neo4j </p> cyan: { dot: "bg-cyan-500", text: "text-cyan-600" },
</div> emerald: { dot: "bg-emerald-500", text: "text-emerald-600" },
};
{/* 实体过滤搜索 */} function Route({ color, name, impl, desc }: { color: string; name: string; impl: string; desc: string }) {
<input const t = ROUTE_TONE[color];
type="text" return (
className="rounded border px-2.5 py-1 text-xs focus:border-violet-500 focus:outline-none w-48 font-mono bg-gray-50/30" <div className="rounded-lg border border-gray-100 bg-gray-50/50 p-3">
placeholder="搜索定位实体节点..." <div className="flex items-center gap-2">
value={searchQuery} <span className={`h-2 w-2 rounded-full ${t.dot}`} />
onChange={(e) => setSearchQuery(e.target.value)} <span className="text-sm font-medium text-gray-700">{name}</span>
/> <span className={`text-[10px] ${t.text}`}>{impl}</span>
</div> </div>
<p className="mt-1 text-[11px] leading-relaxed text-gray-500">{desc}</p>
{/* 拓扑网络画布 */} </div>
<div className="relative border rounded-lg bg-gray-900 overflow-hidden flex-1 min-h-[300px] flex items-center justify-center"> );
<svg viewBox="0 0 360 300" className="w-full h-full select-none cursor-grab active:cursor-grabbing"> }
{/* 画边关系 */}
{EDGES.map((e, idx) => { const STAT_TONE: Record<string, string> = { violet: "text-violet-600", cyan: "text-cyan-600", emerald: "text-emerald-600" };
const fromNode = INITIAL_NODES.find((n) => n.id === e.from)!; function Stat({ label, value, sub, tone }: { label: string; value: string; sub?: string; tone: string }) {
const toNode = INITIAL_NODES.find((n) => n.id === e.to)!; return (
const mx = (fromNode.x + toNode.x) / 2; <div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
const my = (fromNode.y + toNode.y) / 2; <div className="text-xs text-gray-400">{label}</div>
return ( <div className={`mt-1 text-2xl font-semibold tabular-nums ${STAT_TONE[tone] ?? "text-gray-800"}`}>{value}</div>
<g key={idx}> {sub && <div className="mt-1 text-[11px] text-gray-400">{sub}</div>}
<line
x1={fromNode.x}
y1={fromNode.y}
x2={toNode.x}
y2={toNode.y}
stroke="#475569"
strokeWidth="1.5"
strokeDasharray="2 2"
/>
<text x={mx} y={my - 4} fill="#94a3b8" fontSize="8" textAnchor="middle" className="pointer-events-none">
{e.label}
</text>
</g>
);
})}
{/* 画节点 */}
{filteredNodes.map((n) => {
const color = NODE_COLORS[n.type];
const isSelected = selectedNode?.id === n.id;
return (
<g
key={n.id}
className="cursor-pointer group"
onClick={() => setSelectedNode(n)}
>
{/* 高亮光晕 */}
{(n.matched || isSelected) && (
<circle cx={n.x} cy={n.y} r="14" fill={color} opacity="0.3" className="animate-ping" />
)}
{/* 节点本体 */}
<circle
cx={n.x}
cy={n.y}
r={isSelected ? "9" : "7"}
fill={color}
stroke="#ffffff"
strokeWidth="2"
className="transition-all group-hover:scale-125"
/>
{/* 文字标签 */}
<text
x={n.x}
y={n.y - 12}
fill={n.matched ? "#ffffff" : isSelected ? "#a78bfa" : "#e2e8f0"}
fontSize="9"
fontWeight={n.matched || isSelected ? "bold" : "normal"}
textAnchor="middle"
className="pointer-events-none"
>
{n.label}
</text>
</g>
);
})}
</svg>
{/* 左下角小标识 */}
<div className="absolute bottom-2 left-2 flex gap-3 bg-slate-950/70 backdrop-blur border border-slate-800 rounded px-2.5 py-1 text-[9px] text-gray-400">
<span className="flex items-center gap-1.5"><span className="h-1.5 w-1.5 rounded-full bg-violet-600" /></span>
<span className="flex items-center gap-1.5"><span className="h-1.5 w-1.5 rounded-full bg-cyan-500" /></span>
<span className="flex items-center gap-1.5"><span className="h-1.5 w-1.5 rounded-full bg-emerald-500" /></span>
</div>
</div>
</div>
</div>
</div> </div>
); );
} }
@@ -571,6 +571,16 @@ func (h *Handler) AdminUsage(c *gin.Context) {
c.JSON(http.StatusOK, resp) c.JSON(http.StatusOK, resp)
} }
// AdminDatasources: GET /api/v1/admin/datasources —— 数据源清单(全平台知识库 + 文档数/字数)+ 平台计数。
func (h *Handler) AdminDatasources(c *gin.Context) {
ctx := store.WithoutTenant(c.Request.Context())
users, kbs, docs := h.db.SystemCounts(ctx)
c.JSON(http.StatusOK, gin.H{
"counts": gin.H{"users": users, "kbs": kbs, "docs": docs},
"datasources": h.db.AllDatasources(ctx),
})
}
// AdminEvals: GET /api/v1/admin/evals?days= —— 自动评测观测(趋势 + 计数 + 错题本)。全平台口径。 // AdminEvals: GET /api/v1/admin/evals?days= —— 自动评测观测(趋势 + 计数 + 错题本)。全平台口径。
// 数据来自 sundynix_eval(评测经 JetStream eval 流持久落库);此前该页纯 mock。 // 数据来自 sundynix_eval(评测经 JetStream eval 流持久落库);此前该页纯 mock。
func (h *Handler) AdminEvals(c *gin.Context) { func (h *Handler) AdminEvals(c *gin.Context) {
@@ -156,6 +156,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康 admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康
admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额 admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额
admin.GET("/evals", h.AdminEvals) // 自动评测观测:质量趋势 + 计数 + 错题本(真数据) admin.GET("/evals", h.AdminEvals) // 自动评测观测:质量趋势 + 计数 + 错题本(真数据)
admin.GET("/datasources", h.AdminDatasources) // 数据源清单:全平台知识库 + 文档数(真数据)
admin.POST("/migrate-kb-storage", h.MigrateKBStorage) // 增量3:存量 KB 三库 owner/kb→space/kb 重灌(一次性) admin.POST("/migrate-kb-storage", h.MigrateKBStorage) // 增量3:存量 KB 三库 owner/kb→space/kb 重灌(一次性)
admin.GET("/audit", h.AuditList) // 敏感操作审计流(倒序,翻页) admin.GET("/audit", h.AuditList) // 敏感操作审计流(倒序,翻页)
admin.GET("/guardrail-events", h.GuardrailEvents) // 护栏命中安全事件流(倒序,翻页) admin.GET("/guardrail-events", h.GuardrailEvents) // 护栏命中安全事件流(倒序,翻页)
@@ -0,0 +1,36 @@
package store
import "context"
// 数据源清单查询(admin「数据源 & RAG」页做实用;此前该页图谱与权重全 mock)。
// 全平台口径 → WithoutTenant。Doc.KB 存的是 KB 名字(非 id),故按 (space_id, name) 关联。
// DatasourceKB 是一条知识库的清单行(含文档数与总字数、租户名)。
type DatasourceKB struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
TenantID string `json:"tenant_id"`
TenantName string `json:"tenant_name"`
Owner string `json:"owner"`
DocCount int64 `json:"doc_count"`
TotalWords int64 `json:"total_words"`
}
// AllDatasources 列出全平台知识库 + 各库文档数/总字数(admin 数据源清单)。
func (p *Postgres) AllDatasources(ctx context.Context) []DatasourceKB {
if p.db == nil {
return nil
}
var out []DatasourceKB
p.db.WithContext(WithoutTenant(ctx)).Table("sundynix_kb k").
Select("k.id, k.name, k.kind, k.tenant_id, coalesce(t.name,'') as tenant_name, k.owner, "+
"count(d.id) as doc_count, coalesce(sum(d.size),0) as total_words").
Joins("left join sundynix_doc d on d.kb = k.name and d.space_id = k.space_id and d.deleted_at is null").
Joins("left join sundynix_tenant t on t.id = k.tenant_id").
Where("k.deleted_at is null").
Group("k.id, k.name, k.kind, k.tenant_id, t.name, k.owner").
Order("doc_count desc, k.created_at desc").
Scan(&out)
return out
}