From b0323ec89cd323f5ca661c0a44434a9fa64631ae Mon Sep 17 00:00:00 2001 From: iven Date: Wed, 27 May 2026 00:49:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E7=9F=A5=E8=AF=86=E5=BA=93=20V2=20?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E8=BF=81=E7=A7=BB=20+=20=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E5=88=87=E7=89=87=E5=99=A8=20+=20=E5=89=8D=E7=AB=AF=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增迁移 000168:在 AI 知识库同级添加「知识库 V2」菜单,绑定 admin 角色 - 新增 document/chunker.rs:固定大小 + overlap 文本切片器(5 单元测试) - 前端 routeConfig 添加 /health/ai-knowledge-v2 权限声明 - App.tsx validateRouteCoverage 补充 v2 路径 --- apps/web/src/App.tsx | 2 +- apps/web/src/routeConfig.ts | 4 + crates/erp-ai/src/service/document/chunker.rs | 87 +++++++++++++++++++ crates/erp-ai/src/service/mod.rs | 1 + crates/erp-server/migration/src/lib.rs | 2 + .../m20260527_000168_ai_knowledge_v2_menu.rs | 62 +++++++++++++ 6 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 crates/erp-ai/src/service/document/chunker.rs create mode 100644 crates/erp-server/migration/src/m20260527_000168_ai_knowledge_v2_menu.rs 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(()) + } +}