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) => (
+
+
+ } onClick={() => openEdit(record)} />
+
+
+ }
+ onClick={() => handleReEmbed(record.id)}
+ />
+
+ handleDelete(record.id)}
+ okText="删除"
+ cancelText="取消"
+ >
+ } />
+
+
+ ),
+ },
+ ];
+
+ return (
+ <>
+
+
+
+ } onClick={openCreate}>
+ 新增参考资料
+
+
+ } onClick={fetchData}>
+ 刷新
+
+
+
+ `共 ${total} 条` }}
+ size="small"
+ />
+
+ setModalOpen(false)}
+ width={600}
+ destroyOnClose
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+// === Guides Tab ===
+
+function GuidesTab() {
+ 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.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 {found?.label ?? v};
+ },
+ },
+ {
+ title: '分类',
+ dataIndex: 'category',
+ key: 'category',
+ width: 100,
+ render: (v: string | null) => v || '-',
+ },
+ {
+ 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: KnowledgeGuide) => (
+
+
+ } onClick={() => openEdit(record)} />
+
+
+ }
+ onClick={() => handleReEmbed(record.id)}
+ />
+
+ handleDelete(record.id)}
+ okText="删除"
+ cancelText="取消"
+ >
+ } />
+
+
+ ),
+ },
+ ];
+
+ return (
+ <>
+
+
+
+ } onClick={openCreate}>
+ 新增临床指南
+
+
+ } onClick={fetchData}>
+ 刷新
+
+
+
+ `共 ${total} 条` }}
+ size="small"
+ />
+
+ setModalOpen(false)}
+ width={700}
+ destroyOnClose
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/web/src/routeConfig.ts b/apps/web/src/routeConfig.ts
index 936de91..c820f92 100644
--- a/apps/web/src/routeConfig.ts
+++ b/apps/web/src/routeConfig.ts
@@ -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"],
+ },
// ===== 健康管理 — 积分商城 =====
{
diff --git a/crates/erp-ai/src/agent/sandbox.rs b/crates/erp-ai/src/agent/sandbox.rs
index 684fb18..f22c3da 100644
--- a/crates/erp-ai/src/agent/sandbox.rs
+++ b/crates/erp-ai/src/agent/sandbox.rs
@@ -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 {
diff --git a/crates/erp-ai/src/agent/tools/mod.rs b/crates/erp-ai/src/agent/tools/mod.rs
index 34fde35..ef8175e 100644
--- a/crates/erp-ai/src/agent/tools/mod.rs
+++ b/crates/erp-ai/src/agent/tools/mod.rs
@@ -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;
diff --git a/crates/erp-ai/src/agent/tools/search_medical_knowledge.rs b/crates/erp-ai/src/agent/tools/search_medical_knowledge.rs
new file mode 100644
index 0000000..5abaaaf
--- /dev/null
+++ b/crates/erp-ai/src/agent/tools/search_medical_knowledge.rs
@@ -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::(),
+ ));
+ }
+
+ ToolResult {
+ output,
+ display_hint: None,
+ }
+ }
+}
diff --git a/crates/erp-ai/src/handler/chat_handler.rs b/crates/erp-ai/src/handler/chat_handler.rs
index 51fbfe0..bf68082 100644
--- a/crates/erp-ai/src/handler/chat_handler.rs
+++ b/crates/erp-ai/src/handler/chat_handler.rs
@@ -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);
diff --git a/crates/erp-ai/src/handler/knowledge_handler.rs b/crates/erp-ai/src/handler/knowledge_handler.rs
new file mode 100644
index 0000000..6c586b3
--- /dev/null
+++ b/crates/erp-ai/src/handler/knowledge_handler.rs
@@ -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,
+ pub page: Option,
+ pub page_size: Option,
+}
+
+#[utoipa::path(
+ get,
+ path = "/ai/knowledge/references",
+ responses((status = 200, description = "参考资料列表")),
+ tag = "知识库",
+ security(("bearer_auth" = [])),
+)]
+pub async fn list_references(
+ State(state): State,
+ Extension(ctx): Extension,
+ axum::extract::Query(params): axum::extract::Query,
+) -> Result>, erp_core::error::AppError>
+where
+ AiState: FromRef,
+ 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(
+ State(state): State,
+ Extension(ctx): Extension,
+ Json(body): Json,
+) -> Result>, erp_core::error::AppError>
+where
+ AiState: FromRef,
+ 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(
+ State(state): State,
+ Extension(ctx): Extension,
+ Path(id): Path,
+ Json(body): Json,
+) -> Result>, erp_core::error::AppError>
+where
+ AiState: FromRef,
+ 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(
+ State(state): State,
+ Extension(ctx): Extension,
+ Path(id): Path,
+) -> Result>, erp_core::error::AppError>
+where
+ AiState: FromRef,
+ 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(
+ State(state): State,
+ Extension(ctx): Extension,
+ Path(id): Path,
+) -> Result>, erp_core::error::AppError>
+where
+ AiState: FromRef,
+ 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(
+ State(state): State,
+ Extension(ctx): Extension,
+ axum::extract::Query(params): axum::extract::Query,
+) -> Result>, erp_core::error::AppError>
+where
+ AiState: FromRef,
+ 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(
+ State(state): State,
+ Extension(ctx): Extension,
+ Json(body): Json,
+) -> Result>, erp_core::error::AppError>
+where
+ AiState: FromRef,
+ 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(
+ State(state): State,
+ Extension(ctx): Extension,
+ Path(id): Path,
+ Json(body): Json,
+) -> Result>, erp_core::error::AppError>
+where
+ AiState: FromRef,
+ 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(
+ State(state): State,
+ Extension(ctx): Extension,
+ Path(id): Path,
+) -> Result>, erp_core::error::AppError>
+where
+ AiState: FromRef,
+ 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(
+ State(state): State,
+ Extension(ctx): Extension,
+ Path(id): Path,
+) -> Result>, erp_core::error::AppError>
+where
+ AiState: FromRef,
+ 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 }))))
+}
diff --git a/crates/erp-ai/src/handler/mod.rs b/crates/erp-ai/src/handler/mod.rs
index 2e1503b..2a0fa7a 100644
--- a/crates/erp-ai/src/handler/mod.rs
+++ b/crates/erp-ai/src/handler/mod.rs
@@ -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;
diff --git a/crates/erp-ai/src/knowledge/mod.rs b/crates/erp-ai/src/knowledge/mod.rs
index 68fd7b3..7a81869 100644
--- a/crates/erp-ai/src/knowledge/mod.rs
+++ b/crates/erp-ai/src/knowledge/mod.rs
@@ -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};
diff --git a/crates/erp-ai/src/knowledge/vector_source.rs b/crates/erp-ai/src/knowledge/vector_source.rs
new file mode 100644
index 0000000..354f76b
--- /dev/null
+++ b/crates/erp-ai/src/knowledge/vector_source.rs
@@ -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,
+}
+
+impl VectorKnowledgeSource {
+ pub fn new(db: DatabaseConnection, embedding: Arc) -> Self {
+ Self { db, embedding }
+ }
+}
+
+#[async_trait]
+impl KnowledgeSource for VectorKnowledgeSource {
+ async fn get_context(&self, query: &KnowledgeQuery) -> AiResult {
+ 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 = Vec::new();
+ let mut references: Vec = 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 {
+ 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);
+ }
+}
diff --git a/crates/erp-ai/src/module.rs b/crates/erp-ai/src/module.rs
index d6995d4..80b6c70 100644
--- a/crates/erp-ai/src/module.rs
+++ b/crates/erp-ai/src/module.rs
@@ -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),
diff --git a/crates/erp-ai/src/service/knowledge.rs b/crates/erp-ai/src/service/knowledge.rs
index ca7d330..56e103d 100644
--- a/crates/erp-ai/src/service/knowledge.rs
+++ b/crates/erp-ai/src/service/knowledge.rs
@@ -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,
}
-#[derive(Debug, Deserialize, Serialize)]
+#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
pub struct UpdateKnowledgeReferenceReq {
pub title: Option,
pub analysis_type: Option,
@@ -30,7 +30,7 @@ pub struct UpdateKnowledgeReferenceReq {
pub is_enabled: Option,
}
-#[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,
}
-#[derive(Debug, Deserialize, Serialize)]
+#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
pub struct UpdateKnowledgeGuideReq {
pub title: Option,
pub analysis_type: Option,
diff --git a/crates/erp-ai/src/state.rs b/crates/erp-ai/src/state.rs
index b637ce2..1503df9 100644
--- a/crates/erp-ai/src/state.rs
+++ b/crates/erp-ai/src/state.rs
@@ -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,
pub insight_service: Arc,
pub feature_flags: Arc,
+ pub knowledge: Arc,
}
diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs
index ba2c228..22957e8 100644
--- a/crates/erp-server/migration/src/lib.rs
+++ b/crates/erp-server/migration/src/lib.rs
@@ -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),
]
}
}
diff --git a/crates/erp-server/migration/src/m20260519_000154_seed_ai_knowledge_permissions.rs b/crates/erp-server/migration/src/m20260519_000154_seed_ai_knowledge_permissions.rs
new file mode 100644
index 0000000..9c12f37
--- /dev/null
+++ b/crates/erp-server/migration/src/m20260519_000154_seed_ai_knowledge_permissions.rs
@@ -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(())
+ }
+}
diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs
index 21df4f6..99af28b 100644
--- a/crates/erp-server/src/main.rs
+++ b/crates/erp-server/src/main.rs
@@ -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,
+ ),
+ )),
}
};