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:
@@ -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 />} />
|
||||
|
||||
110
apps/web/src/api/ai/knowledge.ts
Normal file
110
apps/web/src/api/ai/knowledge.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
508
apps/web/src/pages/health/AiKnowledgePage.tsx
Normal file
508
apps/web/src/pages/health/AiKnowledgePage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
// ===== 健康管理 — 积分商城 =====
|
||||
{
|
||||
|
||||
@@ -44,6 +44,7 @@ pub fn get_sandbox_config(role: &UserRole) -> SandboxConfig {
|
||||
"query_patient_vitals".into(),
|
||||
"query_patient_lab_reports".into(),
|
||||
"query_patient_medications".into(),
|
||||
"search_medical_knowledge".into(),
|
||||
]),
|
||||
system_prompt_suffix: PATIENT_PROMPT_SUFFIX,
|
||||
output_filter: OutputFilter {
|
||||
@@ -59,6 +60,7 @@ pub fn get_sandbox_config(role: &UserRole) -> SandboxConfig {
|
||||
"query_patient_lab_reports".into(),
|
||||
"query_patient_appointments".into(),
|
||||
"query_patient_medications".into(),
|
||||
"search_medical_knowledge".into(),
|
||||
]),
|
||||
system_prompt_suffix: MEDICAL_STAFF_PROMPT_SUFFIX,
|
||||
output_filter: OutputFilter {
|
||||
|
||||
@@ -4,8 +4,10 @@ pub mod query_appointments;
|
||||
pub mod query_lab_reports;
|
||||
pub mod query_medications;
|
||||
pub mod query_vitals;
|
||||
pub mod search_medical_knowledge;
|
||||
|
||||
pub use query_appointments::QueryAppointmentsTool;
|
||||
pub use query_lab_reports::QueryLabReportsTool;
|
||||
pub use query_medications::QueryMedicationsTool;
|
||||
pub use query_vitals::QueryPatientVitalsTool;
|
||||
pub use search_medical_knowledge::SearchMedicalKnowledgeTool;
|
||||
|
||||
112
crates/erp-ai/src/agent/tools/search_medical_knowledge.rs
Normal file
112
crates/erp-ai/src/agent/tools/search_medical_knowledge.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::agent::tool::{AgentTool, ToolContext, ToolResult};
|
||||
use crate::service::embedding::EmbeddingService;
|
||||
|
||||
/// 语义检索医学知识库(参考资料 + 临床指南)
|
||||
pub struct SearchMedicalKnowledgeTool;
|
||||
|
||||
#[async_trait]
|
||||
impl AgentTool for SearchMedicalKnowledgeTool {
|
||||
fn name(&self) -> &str {
|
||||
"search_medical_knowledge"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"在医学知识库中语义检索相关参考资料和临床指南。输入查询文本,返回最相关的知识条目。可选按分析类型过滤(如 trend、lab_report、dialysis_risk)。"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"required": ["query"],
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "搜索查询文本,如'高血压管理指南'或'血红蛋白偏低原因'"
|
||||
},
|
||||
"analysis_type": {
|
||||
"type": "string",
|
||||
"description": "可选:按分析类型过滤(trend、lab_report、dialysis_risk 等)"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, ctx: &ToolContext, params: serde_json::Value) -> ToolResult {
|
||||
let query_text = match params["query"].as_str() {
|
||||
Some(q) if !q.trim().is_empty() => q.to_string(),
|
||||
_ => {
|
||||
return ToolResult {
|
||||
output: "请提供搜索查询文本".to_string(),
|
||||
display_hint: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let analysis_type = params["analysis_type"].as_str();
|
||||
|
||||
let embedding_svc = EmbeddingService::from_settings(&ctx.db).await;
|
||||
|
||||
if !embedding_svc.is_configured() {
|
||||
return ToolResult {
|
||||
output: "Embedding API 未配置,无法进行语义搜索".to_string(),
|
||||
display_hint: None,
|
||||
};
|
||||
}
|
||||
|
||||
let embedding = match embedding_svc.embed(&query_text).await {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
return ToolResult {
|
||||
output: format!("生成查询向量失败: {}", e),
|
||||
display_hint: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let results = match crate::knowledge::vector_search::KnowledgeSearchRepository::search(
|
||||
&ctx.db,
|
||||
ctx.tenant_id,
|
||||
analysis_type,
|
||||
&embedding,
|
||||
5,
|
||||
0.6,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return ToolResult {
|
||||
output: format!("知识库搜索失败: {}", e),
|
||||
display_hint: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if results.is_empty() {
|
||||
return ToolResult {
|
||||
output: "未找到相关的医学知识条目".to_string(),
|
||||
display_hint: None,
|
||||
};
|
||||
}
|
||||
|
||||
let mut output = String::from("知识库检索结果:\n\n");
|
||||
for (i, r) in results.iter().enumerate() {
|
||||
output.push_str(&format!(
|
||||
"{}. [{}] {}(相似度: {}%)\n 来源: {}\n {}\n\n",
|
||||
i + 1,
|
||||
r.source_table,
|
||||
r.title,
|
||||
(r.similarity * 100.0) as u32,
|
||||
r.source_name,
|
||||
r.content.chars().take(200).collect::<String>(),
|
||||
));
|
||||
}
|
||||
|
||||
ToolResult {
|
||||
output,
|
||||
display_hint: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use crate::agent::orchestrator::AgentRunParams;
|
||||
use crate::agent::sandbox::{get_sandbox_config, resolve_role};
|
||||
use crate::agent::tool::ToolContext;
|
||||
use crate::agent::tools::QueryPatientVitalsTool;
|
||||
use crate::agent::tools::SearchMedicalKnowledgeTool;
|
||||
use crate::agent::tools::{QueryAppointmentsTool, QueryLabReportsTool, QueryMedicationsTool};
|
||||
use crate::agent::{AgentOrchestrator, ToolRegistry};
|
||||
use crate::config_resolver;
|
||||
@@ -121,6 +122,7 @@ where
|
||||
registry.register(std::sync::Arc::new(QueryLabReportsTool));
|
||||
registry.register(std::sync::Arc::new(QueryAppointmentsTool));
|
||||
registry.register(std::sync::Arc::new(QueryMedicationsTool));
|
||||
registry.register(std::sync::Arc::new(SearchMedicalKnowledgeTool));
|
||||
|
||||
// 根据用户角色获取沙箱配置
|
||||
let user_role = resolve_role(&ctx.roles);
|
||||
|
||||
296
crates/erp-ai/src/handler/knowledge_handler.rs
Normal file
296
crates/erp-ai/src/handler/knowledge_handler.rs
Normal file
@@ -0,0 +1,296 @@
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, FromRef, Path, State};
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::service::knowledge::{
|
||||
CreateKnowledgeGuideReq, CreateKnowledgeReferenceReq, ListKnowledgeQuery,
|
||||
UpdateKnowledgeGuideReq, UpdateKnowledgeReferenceReq,
|
||||
};
|
||||
use crate::state::AiState;
|
||||
|
||||
// === References ===
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListKnowledgeParams {
|
||||
pub analysis_type: Option<String>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/ai/knowledge/references",
|
||||
responses((status = 200, description = "参考资料列表")),
|
||||
tag = "知识库",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn list_references<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
axum::extract::Query(params): axum::extract::Query<ListKnowledgeParams>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.list")?;
|
||||
|
||||
let query = ListKnowledgeQuery {
|
||||
analysis_type: params.analysis_type,
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
};
|
||||
let items = state
|
||||
.knowledge
|
||||
.list_references(ctx.tenant_id, &query)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"data": items,
|
||||
"total": items.len(),
|
||||
}))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/ai/knowledge/references",
|
||||
responses((status = 200, description = "创建参考资料")),
|
||||
tag = "知识库",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn create_reference<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(body): Json<CreateKnowledgeReferenceReq>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.manage")?;
|
||||
|
||||
if body.title.trim().is_empty() {
|
||||
return Err(erp_core::error::AppError::Validation("标题不能为空".into()));
|
||||
}
|
||||
|
||||
let id = state
|
||||
.knowledge
|
||||
.create_reference(ctx.tenant_id, ctx.user_id, body)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/ai/knowledge/references/{id}",
|
||||
responses((status = 200, description = "更新参考资料")),
|
||||
tag = "知识库",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn update_reference<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
Json(body): Json<UpdateKnowledgeReferenceReq>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.manage")?;
|
||||
|
||||
state
|
||||
.knowledge
|
||||
.update_reference(ctx.tenant_id, ctx.user_id, id, body)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/ai/knowledge/references/{id}",
|
||||
responses((status = 200, description = "删除参考资料")),
|
||||
tag = "知识库",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn delete_reference<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.manage")?;
|
||||
|
||||
state.knowledge.delete_reference(ctx.tenant_id, id).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/ai/knowledge/references/{id}/re-embed",
|
||||
responses((status = 200, description = "重新生成向量")),
|
||||
tag = "知识库",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn re_embed_reference<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.manage")?;
|
||||
|
||||
state
|
||||
.knowledge
|
||||
.re_embed_reference(ctx.tenant_id, id)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
|
||||
}
|
||||
|
||||
// === Guides ===
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/ai/knowledge/guides",
|
||||
responses((status = 200, description = "临床指南列表")),
|
||||
tag = "知识库",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn list_guides<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
axum::extract::Query(params): axum::extract::Query<ListKnowledgeParams>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.list")?;
|
||||
|
||||
let query = ListKnowledgeQuery {
|
||||
analysis_type: params.analysis_type,
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
};
|
||||
let items = state.knowledge.list_guides(ctx.tenant_id, &query).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"data": items,
|
||||
"total": items.len(),
|
||||
}))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/ai/knowledge/guides",
|
||||
responses((status = 200, description = "创建临床指南")),
|
||||
tag = "知识库",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn create_guide<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(body): Json<CreateKnowledgeGuideReq>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.manage")?;
|
||||
|
||||
if body.title.trim().is_empty() {
|
||||
return Err(erp_core::error::AppError::Validation("标题不能为空".into()));
|
||||
}
|
||||
|
||||
let id = state
|
||||
.knowledge
|
||||
.create_guide(ctx.tenant_id, ctx.user_id, body)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/ai/knowledge/guides/{id}",
|
||||
responses((status = 200, description = "更新临床指南")),
|
||||
tag = "知识库",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn update_guide<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
Json(body): Json<UpdateKnowledgeGuideReq>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.manage")?;
|
||||
|
||||
state
|
||||
.knowledge
|
||||
.update_guide(ctx.tenant_id, ctx.user_id, id, body)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/ai/knowledge/guides/{id}",
|
||||
responses((status = 200, description = "删除临床指南")),
|
||||
tag = "知识库",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn delete_guide<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.manage")?;
|
||||
|
||||
state.knowledge.delete_guide(ctx.tenant_id, id).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/ai/knowledge/guides/{id}/re-embed",
|
||||
responses((status = 200, description = "重新生成向量")),
|
||||
tag = "知识库",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn re_embed_guide<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.manage")?;
|
||||
|
||||
state.knowledge.re_embed_guide(ctx.tenant_id, id).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use crate::state::AiState;
|
||||
pub mod chat_handler;
|
||||
pub mod config_handler;
|
||||
pub mod insight_handler;
|
||||
pub mod knowledge_handler;
|
||||
pub mod risk_handler;
|
||||
pub mod rule_handler;
|
||||
pub mod suggestion_handler;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod structured_source;
|
||||
pub mod vector_search;
|
||||
pub mod vector_source;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
193
crates/erp-ai/src/knowledge/vector_source.rs
Normal file
193
crates/erp-ai/src/knowledge/vector_source.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
//! 向量知识源 — 基于 pgvector 余弦相似度检索,实现 KnowledgeSource trait
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::error::{AiError, AiResult};
|
||||
use crate::service::embedding::EmbeddingService;
|
||||
|
||||
use super::{KnowledgeContext, KnowledgeQuery, KnowledgeSource, Reference};
|
||||
|
||||
/// 向量知识源 — 语义检索参考资料和临床指南
|
||||
pub struct VectorKnowledgeSource {
|
||||
db: DatabaseConnection,
|
||||
embedding: Arc<EmbeddingService>,
|
||||
}
|
||||
|
||||
impl VectorKnowledgeSource {
|
||||
pub fn new(db: DatabaseConnection, embedding: Arc<EmbeddingService>) -> Self {
|
||||
Self { db, embedding }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl KnowledgeSource for VectorKnowledgeSource {
|
||||
async fn get_context(&self, query: &KnowledgeQuery) -> AiResult<KnowledgeContext> {
|
||||
let query_text = match &query.query_text {
|
||||
Some(t) if !t.trim().is_empty() => t.clone(),
|
||||
_ => {
|
||||
// 无查询文本时回退到基于患者标签的简单拼接
|
||||
match &query.patient_context {
|
||||
Some(ctx) if !ctx.tags.is_empty() => ctx.tags.join(" "),
|
||||
_ => {
|
||||
return Ok(KnowledgeContext {
|
||||
source: "vector".into(),
|
||||
context_text: "无查询文本,跳过向量检索".into(),
|
||||
references: vec![],
|
||||
confidence: 0.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !self.embedding.is_configured() {
|
||||
return Ok(KnowledgeContext {
|
||||
source: "vector".into(),
|
||||
context_text: "Embedding API 未配置,跳过向量检索".into(),
|
||||
references: vec![],
|
||||
confidence: 0.0,
|
||||
});
|
||||
}
|
||||
|
||||
let embedding = match self.embedding.embed(&query_text).await {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "向量知识源 embedding 失败");
|
||||
return Ok(KnowledgeContext {
|
||||
source: "vector".into(),
|
||||
context_text: "向量生成失败,跳过检索".into(),
|
||||
references: vec![],
|
||||
confidence: 0.0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let results = crate::knowledge::vector_search::KnowledgeSearchRepository::search(
|
||||
&self.db,
|
||||
query.tenant_id,
|
||||
Some(&query.analysis_type),
|
||||
&embedding,
|
||||
5,
|
||||
0.6,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AiError::KnowledgeError(format!("向量知识检索失败: {}", e)))?;
|
||||
|
||||
if results.is_empty() {
|
||||
return Ok(KnowledgeContext {
|
||||
source: "vector".into(),
|
||||
context_text: "向量检索无匹配结果".into(),
|
||||
references: vec![],
|
||||
confidence: 0.3,
|
||||
});
|
||||
}
|
||||
|
||||
let mut context_parts: Vec<String> = Vec::new();
|
||||
let mut references: Vec<Reference> = Vec::new();
|
||||
|
||||
for r in &results {
|
||||
let content = if r.content.len() > 1500 {
|
||||
&r.content[..1500]
|
||||
} else {
|
||||
&r.content
|
||||
};
|
||||
context_parts.push(format!(
|
||||
"【{}】{}(来源: {},相似度: {}%)\n{}",
|
||||
r.source_table,
|
||||
r.title,
|
||||
r.source_name,
|
||||
(r.similarity * 100.0) as u32,
|
||||
content,
|
||||
));
|
||||
|
||||
references.push(Reference {
|
||||
title: r.title.clone(),
|
||||
source: r.source_name.clone(),
|
||||
relevance_score: r.similarity,
|
||||
});
|
||||
}
|
||||
|
||||
let context_text = {
|
||||
let full = context_parts.join("\n\n");
|
||||
if full.len() > 6000 {
|
||||
full[..6000].to_string()
|
||||
} else {
|
||||
full
|
||||
}
|
||||
};
|
||||
|
||||
let max_similarity = results.iter().map(|r| r.similarity).fold(0.0f32, f32::max);
|
||||
let confidence = if max_similarity >= 0.9 {
|
||||
0.95
|
||||
} else if max_similarity >= 0.8 {
|
||||
0.85
|
||||
} else if max_similarity >= 0.7 {
|
||||
0.75
|
||||
} else {
|
||||
0.6
|
||||
};
|
||||
|
||||
Ok(KnowledgeContext {
|
||||
source: "vector".into(),
|
||||
context_text,
|
||||
references,
|
||||
confidence,
|
||||
})
|
||||
}
|
||||
|
||||
fn source_type(&self) -> &str {
|
||||
"vector"
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> AiResult<bool> {
|
||||
if !self.embedding.is_configured() {
|
||||
return Ok(false);
|
||||
}
|
||||
// 尝试生成一个简单的 embedding 验证 API 可用性
|
||||
match self.embedding.embed("health check").await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "向量知识源健康检查失败");
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn confidence_tiers() {
|
||||
assert!((confidence_for(0.95) - 0.95).abs() < 0.01);
|
||||
assert!((confidence_for(0.85) - 0.85).abs() < 0.01);
|
||||
assert!((confidence_for(0.75) - 0.75).abs() < 0.01);
|
||||
assert!((confidence_for(0.65) - 0.6).abs() < 0.01);
|
||||
}
|
||||
|
||||
fn confidence_for(max_similarity: f32) -> f32 {
|
||||
if max_similarity >= 0.9 {
|
||||
0.95
|
||||
} else if max_similarity >= 0.8 {
|
||||
0.85
|
||||
} else if max_similarity >= 0.7 {
|
||||
0.75
|
||||
} else {
|
||||
0.6
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_truncation_vector() {
|
||||
let long_context = "x".repeat(10000);
|
||||
let truncated = if long_context.len() > 6000 {
|
||||
long_context[..6000].to_string()
|
||||
} else {
|
||||
long_context
|
||||
};
|
||||
assert_eq!(truncated.len(), 6000);
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,19 @@ impl ErpModule for AiModule {
|
||||
description: "查看 AI 客服会话消息历史".into(),
|
||||
module: "ai".into(),
|
||||
},
|
||||
// 知识库权限
|
||||
PermissionDescriptor {
|
||||
code: "ai.knowledge.list".into(),
|
||||
name: "查看知识库".into(),
|
||||
description: "查看 AI 知识库(参考资料和临床指南)".into(),
|
||||
module: "ai".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "ai.knowledge.manage".into(),
|
||||
name: "管理知识库".into(),
|
||||
description: "创建/编辑/删除 AI 知识库条目".into(),
|
||||
module: "ai".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -502,6 +515,47 @@ impl AiModule {
|
||||
"/ai/suggestions/{id}/feedback",
|
||||
axum::routing::post(crate::handler::suggestion_handler::submit_feedback),
|
||||
)
|
||||
// 知识库路由
|
||||
.route(
|
||||
"/ai/knowledge/references",
|
||||
axum::routing::get(crate::handler::knowledge_handler::list_references),
|
||||
)
|
||||
.route(
|
||||
"/ai/knowledge/references",
|
||||
axum::routing::post(crate::handler::knowledge_handler::create_reference),
|
||||
)
|
||||
.route(
|
||||
"/ai/knowledge/references/{id}",
|
||||
axum::routing::put(crate::handler::knowledge_handler::update_reference),
|
||||
)
|
||||
.route(
|
||||
"/ai/knowledge/references/{id}",
|
||||
axum::routing::delete(crate::handler::knowledge_handler::delete_reference),
|
||||
)
|
||||
.route(
|
||||
"/ai/knowledge/references/{id}/re-embed",
|
||||
axum::routing::post(crate::handler::knowledge_handler::re_embed_reference),
|
||||
)
|
||||
.route(
|
||||
"/ai/knowledge/guides",
|
||||
axum::routing::get(crate::handler::knowledge_handler::list_guides),
|
||||
)
|
||||
.route(
|
||||
"/ai/knowledge/guides",
|
||||
axum::routing::post(crate::handler::knowledge_handler::create_guide),
|
||||
)
|
||||
.route(
|
||||
"/ai/knowledge/guides/{id}",
|
||||
axum::routing::put(crate::handler::knowledge_handler::update_guide),
|
||||
)
|
||||
.route(
|
||||
"/ai/knowledge/guides/{id}",
|
||||
axum::routing::delete(crate::handler::knowledge_handler::delete_guide),
|
||||
)
|
||||
.route(
|
||||
"/ai/knowledge/guides/{id}/re-embed",
|
||||
axum::routing::post(crate::handler::knowledge_handler::re_embed_guide),
|
||||
)
|
||||
.route(
|
||||
"/ai/dialysis/risk-assessment",
|
||||
axum::routing::post(crate::handler::assess_dialysis_risk),
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::service::embedding::{EmbeddingService, format_vector};
|
||||
|
||||
// ─── DTO ───
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct CreateKnowledgeReferenceReq {
|
||||
pub title: String,
|
||||
pub analysis_type: String,
|
||||
@@ -20,7 +20,7 @@ pub struct CreateKnowledgeReferenceReq {
|
||||
pub is_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct UpdateKnowledgeReferenceReq {
|
||||
pub title: Option<String>,
|
||||
pub analysis_type: Option<String>,
|
||||
@@ -30,7 +30,7 @@ pub struct UpdateKnowledgeReferenceReq {
|
||||
pub is_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct CreateKnowledgeGuideReq {
|
||||
pub title: String,
|
||||
pub analysis_type: String,
|
||||
@@ -39,7 +39,7 @@ pub struct CreateKnowledgeGuideReq {
|
||||
pub is_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct UpdateKnowledgeGuideReq {
|
||||
pub title: Option<String>,
|
||||
pub analysis_type: Option<String>,
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::service::analysis::AnalysisService;
|
||||
use crate::service::cache::CacheService;
|
||||
use crate::service::feature_flag_service::FeatureFlagService;
|
||||
use crate::service::insight_service::InsightService;
|
||||
use crate::service::knowledge::KnowledgeService;
|
||||
use crate::service::prompt::PromptService;
|
||||
use crate::service::quota::QuotaService;
|
||||
use crate::service::risk_service::RiskService;
|
||||
@@ -30,4 +31,5 @@ pub struct AiState {
|
||||
pub risk_service: Arc<RiskService>,
|
||||
pub insight_service: Arc<InsightService>,
|
||||
pub feature_flags: Arc<FeatureFlagService>,
|
||||
pub knowledge: Arc<KnowledgeService>,
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ mod m20260518_000150_seed_ai_config_permission;
|
||||
mod m20260518_000151_fix_ai_config_menu_parent;
|
||||
mod m20260518_000152_seed_ai_provider_permission;
|
||||
mod m20260518_000153_ai_health_butler_v2;
|
||||
mod m20260519_000154_seed_ai_knowledge_permissions;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -317,6 +318,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260518_000151_fix_ai_config_menu_parent::Migration),
|
||||
Box::new(m20260518_000152_seed_ai_provider_permission::Migration),
|
||||
Box::new(m20260518_000153_ai_health_butler_v2::Migration),
|
||||
Box::new(m20260519_000154_seed_ai_knowledge_permissions::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! AI 知识库权限码 seed + 菜单项
|
||||
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
let sys = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
// 1. Seed 知识库权限码
|
||||
let perms = [
|
||||
(
|
||||
"ai.knowledge.list",
|
||||
"查看知识库",
|
||||
"查看 AI 知识库(参考资料和临床指南)",
|
||||
),
|
||||
(
|
||||
"ai.knowledge.manage",
|
||||
"管理知识库",
|
||||
"创建/编辑/删除 AI 知识库条目",
|
||||
),
|
||||
];
|
||||
|
||||
for (code, name, desc) in &perms {
|
||||
db.execute_unprepared(&format!(
|
||||
r#"
|
||||
INSERT INTO permissions (id, tenant_id, code, name, resource, action, description,
|
||||
created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||||
SELECT gen_random_uuid(), t.id, '{code}', '{name}', 'ai', '{code}', '{desc}',
|
||||
NOW(), NOW(), '{sys}', '{sys}', NULL, 1
|
||||
FROM tenant t
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM permissions p
|
||||
WHERE p.code = '{code}' AND p.tenant_id = t.id AND p.deleted_at IS NULL
|
||||
)
|
||||
"#
|
||||
)).await?;
|
||||
|
||||
// 绑定到管理员角色
|
||||
db.execute_unprepared(&format!(
|
||||
r#"
|
||||
INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope,
|
||||
created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||||
SELECT r.id, p.id, t.id, 'all',
|
||||
NOW(), NOW(), '{sys}', '{sys}', NULL, 1
|
||||
FROM tenant t
|
||||
JOIN roles r ON r.tenant_id = t.id AND r.code = 'admin' AND r.deleted_at IS NULL
|
||||
JOIN permissions p ON p.tenant_id = t.id AND p.code = '{code}' AND p.deleted_at IS NULL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp
|
||||
WHERE rp.role_id = r.id AND rp.permission_id = p.id AND rp.deleted_at IS NULL
|
||||
)
|
||||
ON CONFLICT (role_id, permission_id) DO NOTHING
|
||||
"#
|
||||
)).await?;
|
||||
}
|
||||
|
||||
// 2. 添加知识库菜单项(AI 配置下方)
|
||||
db.execute_unprepared(&format!(
|
||||
r#"
|
||||
INSERT INTO menus (id, tenant_id, parent_id, name, path, icon, sort_order,
|
||||
permission, menu_type, is_external, status,
|
||||
created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||||
SELECT gen_random_uuid(), t.id,
|
||||
(SELECT m.id FROM menus m WHERE m.path = '/health/ai-config' AND m.tenant_id = t.id AND m.deleted_at IS NULL LIMIT 1),
|
||||
'AI 知识库', '/health/ai-knowledge', 'BookOutlined', 4,
|
||||
'ai.knowledge.list', 1, false, 1,
|
||||
NOW(), NOW(), '{sys}', '{sys}', NULL, 1
|
||||
FROM tenant t
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM menus m
|
||||
WHERE m.path = '/health/ai-knowledge' AND m.tenant_id = t.id AND m.deleted_at IS NULL
|
||||
)
|
||||
"#
|
||||
)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -609,6 +609,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
feature_flags: std::sync::Arc::new(
|
||||
erp_ai::service::feature_flag_service::FeatureFlagService::new(db.clone()),
|
||||
),
|
||||
knowledge: std::sync::Arc::new(erp_ai::service::knowledge::KnowledgeService::new(
|
||||
db.clone(),
|
||||
std::sync::Arc::new(
|
||||
erp_ai::service::embedding::EmbeddingService::from_settings(&db).await,
|
||||
),
|
||||
)),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user