From e29bc9a91e9408acac3437aab0bbab0d7bc1c775 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Sat, 18 Jul 2026 12:26:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E3=80=8C=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=BA=90=20&=20RAG=E3=80=8D=E9=A1=B5=E5=81=9A=E5=AE=9E=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20admin=20=E4=B8=89=20mock=20=E9=A1=B5?= =?UTF-8?q?=E6=B8=85=E9=9B=B6=20(P1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审计 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 --- sundynix-admin/src/api.ts | 19 + sundynix-admin/src/pages/DatasourcesPage.tsx | 329 ++++++------------ sundynix-gateway/internal/handler/admin.go | 10 + sundynix-gateway/internal/router/router.go | 1 + .../internal/store/datasource_query.go | 36 ++ 5 files changed, 173 insertions(+), 222 deletions(-) create mode 100644 sundynix-gateway/internal/store/datasource_query.go diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index d92434b..022dece 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -365,6 +365,25 @@ export async function guardrailEvents(limit = 100): Promise { 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 探活(不受鉴权影响)。 export async function gatewayOnline(): Promise { try { diff --git a/sundynix-admin/src/pages/DatasourcesPage.tsx b/sundynix-admin/src/pages/DatasourcesPage.tsx index bdf37c4..250d3f1 100644 --- a/sundynix-admin/src/pages/DatasourcesPage.tsx +++ b/sundynix-admin/src/pages/DatasourcesPage.tsx @@ -1,69 +1,37 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { ModelManager } from "../components/ModelManager"; +import { adminDatasources, type DatasourceKB } from "../api"; -// Mock GraphRAG 拓扑节点 -const INITIAL_NODES = [ - { id: "node_1", x: 180, y: 50, label: "Beta Tech", type: "tenant", desc: "租户空间组织实体" }, - { 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: "退款前提:须有书面正式通知" } -]; +// 数据源 & RAG:真数据(全平台知识库清单,来自 sundynix_kb/sundynix_doc)。 +// 此前该页 GraphRAG 拓扑图与「向量/全文/图谱权重滑块」全 mock——而且权重概念本身是虚构的: +// mcp-go 的 RRF 融合是各路等权的倒排互惠融合(rrfK=60 平滑常数),没有"每路占几成"的权重。 +// 故删假滑块+假拓扑,换成真数据源清单 + 诚实的检索管线说明。 -// Mock GraphRAG 拓扑边关系 -const EDGES = [ - { 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 = { - tenant: "#7c3aed", // 紫罗兰 (Violet) - document: "#06b6d4", // 青色 (Cyan) - concept: "#10b981" // 翠绿 (Emerald) -}; +const KIND_LABEL: Record = { general: "通用", folder: "文件夹", project: "项目", case: "案例" }; +const fmtWords = (n: number) => (n >= 1e4 ? `${(n / 1e4).toFixed(1)}万` : `${n}`); export function DatasourcesPage() { - const [weights, setWeights] = useState({ - vector: 45, - fullText: 35, - graph: 20 - }); + const [counts, setCounts] = useState({ users: 0, kbs: 0, docs: 0 }); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(""); - const [searchQuery, setSearchQuery] = useState(""); - const [selectedNode, setSelectedNode] = useState(null); + useEffect(() => { + adminDatasources() + .then((r) => { + setCounts(r.counts); + setRows(r.datasources); + setErr(""); + }) + .catch((e) => setErr((e as Error).message)) + .finally(() => setLoading(false)); + }, []); - // 权重调整滑动条 - 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 }; - }); + const totalWords = rows.reduce((a, r) => a + r.total_words, 0); return (
- {/* 1. Embedding 模型配置 (真实组件) */} + {/* Embedding 模型配置(真实组件) */} -
- {/* 左侧 RAG 融合检索调优面板 */} -
-
-

RAG 混合检索调参 (RRF 融合权重)

-

动态配置 mcp-go 的倒排互惠排名融合 (Reciprocal Rank Fusion) 权重,调整回答召回倾向

- -
- {/* 向量检索路 */} -
-
- 向量数据库检索权重 (Milvus) - {weights.vector}% -
- handleWeightChange("vector", Number(e.target.value))} - /> -
- - {/* 全文检索路 */} -
-
- 全文倒排索引权重 (Bleve) - {weights.fullText}% -
- handleWeightChange("fullText", Number(e.target.value))} - /> -
- - {/* 知识图谱检索路 */} -
-
- 知识图谱三元组检索权重 (Neo4j) - {weights.graph}% -
- handleWeightChange("graph", Number(e.target.value))} - /> -
- -
- ℹ️ 调参提示:向量路适合语义匹配;全文路防漏精准术语匹配;图谱路用于提供上下文的长链跨行知识召回(GraphRAG)。三路权重加总为 100%。 -
-
-
- - {/* 节点详细信息面板 */} - {selectedNode && ( -
-
- -

{selectedNode.label}

-
-

类型: {selectedNode.type}

-

{selectedNode.desc}

-
- )} + {/* 检索管线说明(诚实:三路 + RRF 等权融合,非可调权重) */} +
+

混合检索管线

+
+ + +
+

+ 三路并行召回 → RRF 倒排互惠融合(Reciprocal Rank Fusion,平滑常数 k=60)→ rerank 重排。 + 融合按各路排名等权,无「每路占几成」的可调权重;单路可经 SearchByMode 指定。检索参数定义在 mcp-go 代码中。 +

+
- {/* 右侧 GraphRAG 图谱拓扑可视化渲染 */} -
-
-
-

GraphRAG 实体知识网络拓扑

-

展示当前租户空间下文档经 LLM 三元组抽取后导入 Neo4j 形成的实体关联网络

-
- - {/* 实体过滤搜索 */} - setSearchQuery(e.target.value)} - /> + {/* 平台数据源计数 */} +
+ + + +
+ + {/* 知识库清单(真数据) */} +
+

知识库清单(全平台)

+ {loading ? ( +
加载中…
+ ) : err ? ( +
加载失败:{err}
+ ) : rows.length === 0 ? ( +
还没有知识库
+ ) : ( +
+ + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + ))} + +
知识库类型租户文档数总字数
{r.name}{KIND_LABEL[r.kind] ?? r.kind}{r.tenant_name || r.tenant_id || "—"}{r.doc_count}{fmtWords(r.total_words)}
- - {/* 拓扑网络画布 */} -
- - {/* 画边关系 */} - {EDGES.map((e, idx) => { - const fromNode = INITIAL_NODES.find((n) => n.id === e.from)!; - const toNode = INITIAL_NODES.find((n) => n.id === e.to)!; - const mx = (fromNode.x + toNode.x) / 2; - const my = (fromNode.y + toNode.y) / 2; - return ( - - - - {e.label} - - - ); - })} - - {/* 画节点 */} - {filteredNodes.map((n) => { - const color = NODE_COLORS[n.type]; - const isSelected = selectedNode?.id === n.id; - return ( - setSelectedNode(n)} - > - {/* 高亮光晕 */} - {(n.matched || isSelected) && ( - - )} - {/* 节点本体 */} - - {/* 文字标签 */} - - {n.label} - - - ); - })} - - - {/* 左下角小标识 */} -
- 租户 - 文档 - 实体概念 -
-
-
+ )}
); } + +const ROUTE_TONE: Record = { + violet: { dot: "bg-violet-500", text: "text-violet-600" }, + cyan: { dot: "bg-cyan-500", text: "text-cyan-600" }, + emerald: { dot: "bg-emerald-500", text: "text-emerald-600" }, +}; +function Route({ color, name, impl, desc }: { color: string; name: string; impl: string; desc: string }) { + const t = ROUTE_TONE[color]; + return ( +
+
+ + {name} + {impl} +
+

{desc}

+
+ ); +} + +const STAT_TONE: Record = { violet: "text-violet-600", cyan: "text-cyan-600", emerald: "text-emerald-600" }; +function Stat({ label, value, sub, tone }: { label: string; value: string; sub?: string; tone: string }) { + return ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+ ); +} diff --git a/sundynix-gateway/internal/handler/admin.go b/sundynix-gateway/internal/handler/admin.go index 4b6c724..0ae8bfd 100644 --- a/sundynix-gateway/internal/handler/admin.go +++ b/sundynix-gateway/internal/handler/admin.go @@ -571,6 +571,16 @@ func (h *Handler) AdminUsage(c *gin.Context) { 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= —— 自动评测观测(趋势 + 计数 + 错题本)。全平台口径。 // 数据来自 sundynix_eval(评测经 JetStream eval 流持久落库);此前该页纯 mock。 func (h *Handler) AdminEvals(c *gin.Context) { diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 5cb0776..fd84dff 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -156,6 +156,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康 admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额 admin.GET("/evals", h.AdminEvals) // 自动评测观测:质量趋势 + 计数 + 错题本(真数据) + admin.GET("/datasources", h.AdminDatasources) // 数据源清单:全平台知识库 + 文档数(真数据) admin.POST("/migrate-kb-storage", h.MigrateKBStorage) // 增量3:存量 KB 三库 owner/kb→space/kb 重灌(一次性) admin.GET("/audit", h.AuditList) // 敏感操作审计流(倒序,翻页) admin.GET("/guardrail-events", h.GuardrailEvents) // 护栏命中安全事件流(倒序,翻页) diff --git a/sundynix-gateway/internal/store/datasource_query.go b/sundynix-gateway/internal/store/datasource_query.go new file mode 100644 index 0000000..d3b24fc --- /dev/null +++ b/sundynix-gateway/internal/store/datasource_query.go @@ -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 +}