feat(ai): Phase 3A RAG 知识库 — CRUD API + Agent Tool + 向量知识源 + 前端管理页

- 知识库 REST API: 10 个端点 (references/guides CRUD + re-embed)
- search_medical_knowledge Agent Tool: 语义检索参考资料和临床指南
- VectorKnowledgeSource: 实现 KnowledgeSource trait,自动降级
- 沙箱配置: Patient/MedicalStaff 允许使用知识库检索
- 前端 AiKnowledgePage: Tabs(参考资料/临床指南) + Table + Modal CRUD
- 权限码 seed 迁移: ai.knowledge.list + ai.knowledge.manage + 菜单
This commit is contained in:
iven
2026-05-19 09:10:53 +08:00
parent c0570dfbfc
commit 8b88cb4a50
18 changed files with 1389 additions and 5 deletions

View File

@@ -46,6 +46,7 @@ const AiPromptList = lazy(() => import('./pages/health/AiPromptList'));
const AiAnalysisList = lazy(() => import('./pages/health/AiAnalysisList'));
const AiUsageDashboard = lazy(() => import('./pages/health/AiUsageDashboard'));
const AiConfigPage = lazy(() => import('./pages/health/AiConfigPage'));
const AiKnowledgePage = lazy(() => import('./pages/health/AiKnowledgePage'));
const AlertList = lazy(() => import('./pages/health/AlertList'));
const AlertDashboard = lazy(() => import('./pages/health/AlertDashboard'));
const AlertRuleList = lazy(() => import('./pages/health/AlertRuleList'));
@@ -255,7 +256,7 @@ export default function App() {
"/health/follow-up-records", "/health/consultations",
"/health/points-rules", "/health/points-products", "/health/points-orders",
"/health/offline-events", "/health/ai-prompts", "/health/ai-analysis",
"/health/ai-usage", "/health/ai-config", "/health/alerts", "/health/alert-dashboard",
"/health/ai-usage", "/health/ai-config", "/health/ai-knowledge", "/health/alerts", "/health/alert-dashboard",
"/health/alert-rules", "/health/devices", "/health/realtime-monitor",
"/health/oauth-clients", "/health/dialysis", "/health/action-inbox",
"/health/follow-up-templates", "/health/care-plans", "/health/shifts",
@@ -327,6 +328,7 @@ export default function App() {
<Route path="/health/ai-analysis" element={<AiAnalysisList />} />
<Route path="/health/ai-usage" element={<AiUsageDashboard />} />
<Route path="/health/ai-config" element={<AiConfigPage />} />
<Route path="/health/ai-knowledge" element={<AiKnowledgePage />} />
<Route path="/health/alerts" element={<AlertList />} />
<Route path="/health/alert-dashboard" element={<AlertDashboard />} />
<Route path="/health/alert-rules" element={<AlertRuleList />} />

View File

@@ -0,0 +1,110 @@
import client from '../client';
// === Types ===
export interface KnowledgeReference {
id: string;
tenant_id: string;
title: string;
analysis_type: string;
source_name: string;
content_summary: string;
tags: Record<string, unknown> | null;
is_enabled: boolean;
created_at: string;
updated_at: string;
}
export interface KnowledgeGuide {
id: string;
tenant_id: string;
title: string;
analysis_type: string;
content: string;
category: string | null;
is_enabled: boolean;
created_at: string;
updated_at: string;
}
export interface CreateReferenceReq {
title: string;
analysis_type: string;
source_name: string;
content_summary: string;
tags?: Record<string, unknown>;
is_enabled?: boolean;
}
export interface UpdateReferenceReq {
title?: string;
analysis_type?: string;
source_name?: string;
content_summary?: string;
tags?: Record<string, unknown>;
is_enabled?: boolean;
}
export interface CreateGuideReq {
title: string;
analysis_type: string;
content: string;
category?: string;
is_enabled?: boolean;
}
export interface UpdateGuideReq {
title?: string;
analysis_type?: string;
content?: string;
category?: string;
is_enabled?: boolean;
}
// === API ===
export const knowledgeApi = {
// References
listReferences: async (params?: { analysis_type?: string }) => {
const resp = await client.get('/ai/knowledge/references', { params });
return resp.data.data as { data: KnowledgeReference[]; total: number };
},
createReference: async (data: CreateReferenceReq) => {
const resp = await client.post('/ai/knowledge/references', data);
return resp.data.data as { id: string };
},
updateReference: async (id: string, data: UpdateReferenceReq) => {
const resp = await client.put(`/ai/knowledge/references/${id}`, data);
return resp.data.data as { id: string };
},
deleteReference: async (id: string) => {
const resp = await client.delete(`/ai/knowledge/references/${id}`);
return resp.data.data as { id: string };
},
reEmbedReference: async (id: string) => {
const resp = await client.post(`/ai/knowledge/references/${id}/re-embed`);
return resp.data.data as { id: string };
},
// Guides
listGuides: async (params?: { analysis_type?: string }) => {
const resp = await client.get('/ai/knowledge/guides', { params });
return resp.data.data as { data: KnowledgeGuide[]; total: number };
},
createGuide: async (data: CreateGuideReq) => {
const resp = await client.post('/ai/knowledge/guides', data);
return resp.data.data as { id: string };
},
updateGuide: async (id: string, data: UpdateGuideReq) => {
const resp = await client.put(`/ai/knowledge/guides/${id}`, data);
return resp.data.data as { id: string };
},
deleteGuide: async (id: string) => {
const resp = await client.delete(`/ai/knowledge/guides/${id}`);
return resp.data.data as { id: string };
},
reEmbedGuide: async (id: string) => {
const resp = await client.post(`/ai/knowledge/guides/${id}/re-embed`);
return resp.data.data as { id: string };
},
};

View File

@@ -0,0 +1,508 @@
import { useCallback, useEffect, useState } from 'react';
import {
Card,
Table,
Button,
Space,
Modal,
Form,
Input,
Select,
Switch,
message,
Popconfirm,
Tabs,
Tag,
Tooltip,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
ReloadOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import {
knowledgeApi,
type KnowledgeReference,
type KnowledgeGuide,
type CreateReferenceReq,
type UpdateReferenceReq,
type CreateGuideReq,
type UpdateGuideReq,
} from '../../api/ai/knowledge';
import { AuthButton } from '../../components/AuthButton';
const ANALYSIS_TYPES = [
{ value: 'lab_report', label: '化验报告' },
{ value: 'trend', label: '趋势分析' },
{ value: 'report_summary', label: '报告摘要' },
{ value: 'dialysis_risk', label: '透析风险' },
{ value: 'checkup_plan', label: '体检计划' },
{ value: 'follow_up', label: '随访总结' },
];
export default function AiKnowledgePage() {
return (
<Card title="AI 知识库管理">
<Tabs
items={[
{ key: 'references', label: '参考资料', children: <ReferencesTab /> },
{ key: 'guides', label: '临床指南', children: <GuidesTab /> },
]}
/>
</Card>
);
}
// === References Tab ===
function ReferencesTab() {
const [data, setData] = useState<KnowledgeReference[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<KnowledgeReference | null>(null);
const [filterType, setFilterType] = useState<string | undefined>();
const [form] = Form.useForm();
const fetchData = useCallback(async () => {
setLoading(true);
try {
const result = await knowledgeApi.listReferences(
filterType ? { analysis_type: filterType } : undefined,
);
setData(result.data);
} catch {
message.error('加载参考资料失败');
} finally {
setLoading(false);
}
}, [filterType]);
useEffect(() => {
fetchData();
}, [fetchData]);
const openCreate = () => {
setEditing(null);
form.resetFields();
form.setFieldsValue({ is_enabled: true });
setModalOpen(true);
};
const openEdit = (record: KnowledgeReference) => {
setEditing(record);
form.setFieldsValue({
title: record.title,
analysis_type: record.analysis_type,
source_name: record.source_name,
content_summary: record.content_summary,
is_enabled: record.is_enabled,
});
setModalOpen(true);
};
const handleSubmit = async () => {
const values = await form.validateFields();
try {
if (editing) {
const req: UpdateReferenceReq = {
title: values.title,
analysis_type: values.analysis_type,
source_name: values.source_name,
content_summary: values.content_summary,
is_enabled: values.is_enabled,
};
await knowledgeApi.updateReference(editing.id, req);
message.success('更新成功');
} else {
const req: CreateReferenceReq = {
title: values.title,
analysis_type: values.analysis_type,
source_name: values.source_name,
content_summary: values.content_summary,
is_enabled: values.is_enabled,
};
await knowledgeApi.createReference(req);
message.success('创建成功');
}
setModalOpen(false);
fetchData();
} catch {
message.error(editing ? '更新失败' : '创建失败');
}
};
const handleDelete = async (id: string) => {
try {
await knowledgeApi.deleteReference(id);
message.success('删除成功');
fetchData();
} catch {
message.error('删除失败');
}
};
const handleReEmbed = async (id: string) => {
try {
await knowledgeApi.reEmbedReference(id);
message.success('向量重新生成已触发');
} catch {
message.error('向量重新生成失败');
}
};
const columns = [
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
{
title: '分析类型',
dataIndex: 'analysis_type',
key: 'analysis_type',
width: 120,
render: (v: string) => {
const found = ANALYSIS_TYPES.find((t) => t.value === v);
return <Tag>{found?.label ?? v}</Tag>;
},
},
{ title: '来源', dataIndex: 'source_name', key: 'source_name', width: 150, ellipsis: true },
{
title: '状态',
dataIndex: 'is_enabled',
key: 'is_enabled',
width: 80,
render: (v: boolean) => (
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
key: 'updated_at',
width: 170,
render: (v: string) => (v ? new Date(v).toLocaleString() : '-'),
},
{
title: '操作',
key: 'actions',
width: 200,
render: (_: unknown, record: KnowledgeReference) => (
<Space size="small">
<Tooltip title="编辑">
<Button type="text" icon={<EditOutlined />} onClick={() => openEdit(record)} />
</Tooltip>
<Tooltip title="重新生成向量">
<Button
type="text"
icon={<ThunderboltOutlined />}
onClick={() => handleReEmbed(record.id)}
/>
</Tooltip>
<Popconfirm
title="确定删除此参考资料?"
onConfirm={() => handleDelete(record.id)}
okText="删除"
cancelText="取消"
>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<>
<Space style={{ marginBottom: 16 }}>
<Select
allowClear
placeholder="按分析类型过滤"
style={{ width: 180 }}
options={ANALYSIS_TYPES}
value={filterType}
onChange={setFilterType}
/>
<AuthButton code="ai.knowledge.manage">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</AuthButton>
<Button icon={<ReloadOutlined />} onClick={fetchData}>
</Button>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
size="small"
/>
<Modal
title={editing ? '编辑参考资料' : '新增参考资料'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => setModalOpen(false)}
width={600}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }]}>
<Input />
</Form.Item>
<Form.Item
name="analysis_type"
label="分析类型"
rules={[{ required: true, message: '请选择分析类型' }]}
>
<Select options={ANALYSIS_TYPES} />
</Form.Item>
<Form.Item name="source_name" label="来源名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item
name="content_summary"
label="内容摘要"
rules={[{ required: true, message: '请输入内容摘要' }]}
>
<Input.TextArea rows={4} />
</Form.Item>
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</>
);
}
// === Guides Tab ===
function GuidesTab() {
const [data, setData] = useState<KnowledgeGuide[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<KnowledgeGuide | null>(null);
const [filterType, setFilterType] = useState<string | undefined>();
const [form] = Form.useForm();
const fetchData = useCallback(async () => {
setLoading(true);
try {
const result = await knowledgeApi.listGuides(
filterType ? { analysis_type: filterType } : undefined,
);
setData(result.data);
} catch {
message.error('加载临床指南失败');
} finally {
setLoading(false);
}
}, [filterType]);
useEffect(() => {
fetchData();
}, [fetchData]);
const openCreate = () => {
setEditing(null);
form.resetFields();
form.setFieldsValue({ is_enabled: true });
setModalOpen(true);
};
const openEdit = (record: KnowledgeGuide) => {
setEditing(record);
form.setFieldsValue({
title: record.title,
analysis_type: record.analysis_type,
content: record.content,
category: record.category,
is_enabled: record.is_enabled,
});
setModalOpen(true);
};
const handleSubmit = async () => {
const values = await form.validateFields();
try {
if (editing) {
const req: UpdateGuideReq = {
title: values.title,
analysis_type: values.analysis_type,
content: values.content,
category: values.category,
is_enabled: values.is_enabled,
};
await knowledgeApi.updateGuide(editing.id, req);
message.success('更新成功');
} else {
const req: CreateGuideReq = {
title: values.title,
analysis_type: values.analysis_type,
content: values.content,
category: values.category,
is_enabled: values.is_enabled,
};
await knowledgeApi.createGuide(req);
message.success('创建成功');
}
setModalOpen(false);
fetchData();
} catch {
message.error(editing ? '更新失败' : '创建失败');
}
};
const handleDelete = async (id: string) => {
try {
await knowledgeApi.deleteGuide(id);
message.success('删除成功');
fetchData();
} catch {
message.error('删除失败');
}
};
const handleReEmbed = async (id: string) => {
try {
await knowledgeApi.reEmbedGuide(id);
message.success('向量重新生成已触发');
} catch {
message.error('向量重新生成失败');
}
};
const columns = [
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
{
title: '分析类型',
dataIndex: 'analysis_type',
key: 'analysis_type',
width: 120,
render: (v: string) => {
const found = ANALYSIS_TYPES.find((t) => t.value === v);
return <Tag>{found?.label ?? v}</Tag>;
},
},
{
title: '分类',
dataIndex: 'category',
key: 'category',
width: 100,
render: (v: string | null) => v || '-',
},
{
title: '状态',
dataIndex: 'is_enabled',
key: 'is_enabled',
width: 80,
render: (v: boolean) => (
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
key: 'updated_at',
width: 170,
render: (v: string) => (v ? new Date(v).toLocaleString() : '-'),
},
{
title: '操作',
key: 'actions',
width: 200,
render: (_: unknown, record: KnowledgeGuide) => (
<Space size="small">
<Tooltip title="编辑">
<Button type="text" icon={<EditOutlined />} onClick={() => openEdit(record)} />
</Tooltip>
<Tooltip title="重新生成向量">
<Button
type="text"
icon={<ThunderboltOutlined />}
onClick={() => handleReEmbed(record.id)}
/>
</Tooltip>
<Popconfirm
title="确定删除此临床指南?"
onConfirm={() => handleDelete(record.id)}
okText="删除"
cancelText="取消"
>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<>
<Space style={{ marginBottom: 16 }}>
<Select
allowClear
placeholder="按分析类型过滤"
style={{ width: 180 }}
options={ANALYSIS_TYPES}
value={filterType}
onChange={setFilterType}
/>
<AuthButton code="ai.knowledge.manage">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</AuthButton>
<Button icon={<ReloadOutlined />} onClick={fetchData}>
</Button>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
size="small"
/>
<Modal
title={editing ? '编辑临床指南' : '新增临床指南'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => setModalOpen(false)}
width={700}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }]}>
<Input />
</Form.Item>
<Form.Item
name="analysis_type"
label="分析类型"
rules={[{ required: true, message: '请选择分析类型' }]}
>
<Select options={ANALYSIS_TYPES} />
</Form.Item>
<Form.Item name="category" label="分类">
<Input placeholder="如:心血管、内分泌" />
</Form.Item>
<Form.Item
name="content"
label="指南内容"
rules={[{ required: true, message: '请输入指南内容' }]}
>
<Input.TextArea rows={8} />
</Form.Item>
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</>
);
}

View File

@@ -147,6 +147,10 @@ const ENTRIES: RoutePermissionEntry[] = [
path: "/health/ai-config",
permissions: ["ai.config.read", "ai.config.manage"],
},
{
path: "/health/ai-knowledge",
permissions: ["ai.knowledge.list", "ai.knowledge.manage"],
},
// ===== 健康管理 — 积分商城 =====
{