feat(ai): 知识库 V2 菜单迁移 + 文本切片器 + 前端路由权限

- 新增迁移 000168:在 AI 知识库同级添加「知识库 V2」菜单,绑定 admin 角色
- 新增 document/chunker.rs:固定大小 + overlap 文本切片器(5 单元测试)
- 前端 routeConfig 添加 /health/ai-knowledge-v2 权限声明
- App.tsx validateRouteCoverage 补充 v2 路径
This commit is contained in:
iven
2026-05-27 00:49:27 +08:00
parent 2324d770bc
commit b0323ec89c
6 changed files with 157 additions and 1 deletions

View File

@@ -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",

View File

@@ -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"],
},
// ===== 健康管理 — 积分商城 =====
{

View File

@@ -0,0 +1,87 @@
/// 文本切片:按固定大小 + 重叠切分
pub fn chunk_text(text: &str, chunk_size: usize, overlap: usize) -> Vec<String> {
if text.is_empty() {
return vec![];
}
let chars: Vec<char> = 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::<String>::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()
}
}

View File

@@ -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;

View File

@@ -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),
]
}
}

View File

@@ -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(())
}
}