From 8b88cb4a502d480836d73ff9fe7b65f3e83e622e Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 19 May 2026 09:10:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20Phase=203A=20RAG=20=E7=9F=A5?= =?UTF-8?q?=E8=AF=86=E5=BA=93=20=E2=80=94=20CRUD=20API=20+=20Agent=20Tool?= =?UTF-8?q?=20+=20=E5=90=91=E9=87=8F=E7=9F=A5=E8=AF=86=E6=BA=90=20+=20?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E7=AE=A1=E7=90=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 知识库 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 + 菜单 --- apps/web/src/App.tsx | 4 +- apps/web/src/api/ai/knowledge.ts | 110 ++++ apps/web/src/pages/health/AiKnowledgePage.tsx | 508 ++++++++++++++++++ apps/web/src/routeConfig.ts | 4 + crates/erp-ai/src/agent/sandbox.rs | 2 + crates/erp-ai/src/agent/tools/mod.rs | 2 + .../agent/tools/search_medical_knowledge.rs | 112 ++++ crates/erp-ai/src/handler/chat_handler.rs | 2 + .../erp-ai/src/handler/knowledge_handler.rs | 296 ++++++++++ crates/erp-ai/src/handler/mod.rs | 1 + crates/erp-ai/src/knowledge/mod.rs | 1 + crates/erp-ai/src/knowledge/vector_source.rs | 193 +++++++ crates/erp-ai/src/module.rs | 54 ++ crates/erp-ai/src/service/knowledge.rs | 8 +- crates/erp-ai/src/state.rs | 2 + crates/erp-server/migration/src/lib.rs | 2 + ...19_000154_seed_ai_knowledge_permissions.rs | 87 +++ crates/erp-server/src/main.rs | 6 + 18 files changed, 1389 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/api/ai/knowledge.ts create mode 100644 apps/web/src/pages/health/AiKnowledgePage.tsx create mode 100644 crates/erp-ai/src/agent/tools/search_medical_knowledge.rs create mode 100644 crates/erp-ai/src/handler/knowledge_handler.rs create mode 100644 crates/erp-ai/src/knowledge/vector_source.rs create mode 100644 crates/erp-server/migration/src/m20260519_000154_seed_ai_knowledge_permissions.rs diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index cbaf68c..5593241 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/api/ai/knowledge.ts b/apps/web/src/api/ai/knowledge.ts new file mode 100644 index 0000000..40bf9c0 --- /dev/null +++ b/apps/web/src/api/ai/knowledge.ts @@ -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 | 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; + is_enabled?: boolean; +} + +export interface UpdateReferenceReq { + title?: string; + analysis_type?: string; + source_name?: string; + content_summary?: string; + tags?: Record; + 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 }; + }, +}; diff --git a/apps/web/src/pages/health/AiKnowledgePage.tsx b/apps/web/src/pages/health/AiKnowledgePage.tsx new file mode 100644 index 0000000..418ecb7 --- /dev/null +++ b/apps/web/src/pages/health/AiKnowledgePage.tsx @@ -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 ( + + }, + { key: 'guides', label: '临床指南', children: }, + ]} + /> + + ); +} + +// === References Tab === + +function ReferencesTab() { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [filterType, setFilterType] = useState(); + 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 {found?.label ?? v}; + }, + }, + { title: '来源', dataIndex: 'source_name', key: 'source_name', width: 150, ellipsis: true }, + { + title: '状态', + dataIndex: 'is_enabled', + key: 'is_enabled', + width: 80, + render: (v: boolean) => ( + {v ? '启用' : '禁用'} + ), + }, + { + 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) => ( + + +