diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 1433777..e0c18bf 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -258,7 +258,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/ai-knowledge", "/health/alerts", "/health/alert-dashboard", + "/health/ai-usage", "/health/ai-config", "/health/ai-knowledge", "/health/ai-knowledge-v2", "/health/alerts", "/health/alert-dashboard", "/ai/chat", "/health/alert-rules", "/health/devices", "/health/realtime-monitor", "/health/oauth-clients", "/health/dialysis", "/health/action-inbox", diff --git a/apps/web/src/routeConfig.ts b/apps/web/src/routeConfig.ts index 7dab879..636cda4 100644 --- a/apps/web/src/routeConfig.ts +++ b/apps/web/src/routeConfig.ts @@ -151,6 +151,10 @@ const ENTRIES: RoutePermissionEntry[] = [ path: "/health/ai-knowledge", permissions: ["ai.knowledge.list", "ai.knowledge.manage"], }, + { + path: "/health/ai-knowledge-v2", + permissions: ["ai.knowledge.list", "ai.knowledge.manage"], + }, // ===== 健康管理 — 积分商城 ===== { diff --git a/crates/erp-ai/src/service/document/chunker.rs b/crates/erp-ai/src/service/document/chunker.rs new file mode 100644 index 0000000..8666128 --- /dev/null +++ b/crates/erp-ai/src/service/document/chunker.rs @@ -0,0 +1,87 @@ +/// 文本切片:按固定大小 + 重叠切分 +pub fn chunk_text(text: &str, chunk_size: usize, overlap: usize) -> Vec { + if text.is_empty() { + return vec![]; + } + + let chars: Vec = text.chars().collect(); + let total = chars.len(); + + if total <= chunk_size { + return vec![text.to_string()]; + } + + let mut chunks = Vec::new(); + let mut start = 0; + + while start < total { + let end = (start + chunk_size).min(total); + let chunk: String = chars[start..end].iter().collect(); + + let trimmed = chunk.trim().to_string(); + if !trimmed.is_empty() { + chunks.push(trimmed); + } + + if end >= total { + break; + } + start += chunk_size.saturating_sub(overlap); + + // 防止无限循环 + if start <= end - chunk_size && start > 0 { + start = end; + } + } + + chunks +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_chunk_empty() { + assert_eq!(chunk_text("", 100, 20), Vec::::new()); + } + + #[test] + fn test_chunk_small_text() { + let text = "hello world"; + let chunks = chunk_text(text, 100, 20); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0], "hello world"); + } + + #[test] + fn test_chunk_long_text() { + let text = "abcdefghij".repeat(100); // 1000 chars + let chunks = chunk_text(&text, 200, 50); + assert!(chunks.len() > 1); + // First chunk should be 200 chars + assert_eq!(chars_count(&chunks[0]), 200); + } + + #[test] + fn test_chunk_with_overlap() { + let text = "abcdefghijklmnopqrstuvwxyz".repeat(20); // 520 chars + let chunks = chunk_text(&text, 100, 20); + assert!(chunks.len() > 1); + } + + #[test] + fn test_chunk_chinese() { + let text = "你好世界这是一段中文测试文本。".repeat(30); + let chunks = chunk_text(&text, 100, 20); + assert!(chunks.len() > 1); + // 确保中文不被截断 + for chunk in &chunks { + assert!(!chunk.is_empty()); + } + } + + fn chars_count(s: &str) -> usize { + s.chars().count() + } +} diff --git a/crates/erp-ai/src/service/mod.rs b/crates/erp-ai/src/service/mod.rs index c192f76..bd02050 100644 --- a/crates/erp-ai/src/service/mod.rs +++ b/crates/erp-ai/src/service/mod.rs @@ -7,6 +7,7 @@ pub mod chat_session; pub mod comparison; pub mod cost; pub mod dialysis_risk_scorer; +pub mod document; pub mod embedding; pub mod feature_flag_service; pub mod insight_service; diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 57ae836..385ab71 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -174,6 +174,7 @@ mod m20260526_000164_ai_prompt_add_analysis_type; mod m20260526_000165_ai_prompt_fix_analysis_type; mod m20260526_000166_create_ai_knowledge_bases; mod m20260526_000167_create_ai_knowledge_documents; +mod m20260527_000168_ai_knowledge_v2_menu; pub struct Migrator; @@ -355,6 +356,7 @@ impl MigratorTrait for Migrator { Box::new(m20260526_000165_ai_prompt_fix_analysis_type::Migration), Box::new(m20260526_000166_create_ai_knowledge_bases::Migration), Box::new(m20260526_000167_create_ai_knowledge_documents::Migration), + Box::new(m20260527_000168_ai_knowledge_v2_menu::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260527_000168_ai_knowledge_v2_menu.rs b/crates/erp-server/migration/src/m20260527_000168_ai_knowledge_v2_menu.rs new file mode 100644 index 0000000..779f3ca --- /dev/null +++ b/crates/erp-server/migration/src/m20260527_000168_ai_knowledge_v2_menu.rs @@ -0,0 +1,62 @@ +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> { + // 在"AI 知识库"同级位置添加"知识库 V2"菜单 + // parent_id 取旧 ai-knowledge 菜单的 parent_id + let sql = r#" + INSERT INTO sys_menu (id, parent_id, name, path, icon, sort, permission, component, is_external, is_cached, status, visible, created_at, updated_at, deleted_at) + SELECT + gen_random_uuid(), + parent_id, + '知识库 V2', + '/health/ai-knowledge-v2', + 'DatabaseOutlined', + 54, + 'ai.knowledge.list', + 'ai/KnowledgeV2Page', + false, + false, + 'active', + true, + now(), + now(), + NULL + FROM sys_menu + WHERE path = '/health/ai-knowledge' AND deleted_at IS NULL + LIMIT 1 + ON CONFLICT DO NOTHING + "#; + + manager.get_connection().execute_unprepared(sql).await?; + + // 绑定到 admin 角色 + let role_sql = r#" + INSERT INTO sys_role_menu (role_id, menu_id) + SELECT r.id, m.id + FROM sys_role r, sys_menu m + WHERE r.code = 'admin' AND r.deleted_at IS NULL + AND m.path = '/health/ai-knowledge-v2' AND m.deleted_at IS NULL + ON CONFLICT DO NOTHING + "#; + + manager + .get_connection() + .execute_unprepared(role_sql) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared("DELETE FROM sys_menu WHERE path = '/health/ai-knowledge-v2'") + .await?; + Ok(()) + } +}