Files
hms/docs/superpowers/plans/2026-05-26-ai-knowledge-base-v2-plan.md
iven dda8be9079 docs(ai): AI 知识库 V2 实施计划 — 7 Phase / 26 Tasks
Phase 0: 数据库迁移 + Entity (3 Tasks)
Phase 1: 后端 Service + Handler (4 Tasks)
Phase 2: 文档处理管线 (5 Tasks)
Phase 3: 混合意图路由 (3 Tasks)
Phase 4: 前端管理 UI (4 Tasks)
Phase 5: AI 客服集成 + 数据迁移 (4 Tasks)
Phase 6: 测试 + 验收 (3 Tasks)
2026-05-26 23:00:46 +08:00

1185 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AI 知识库 V2 实施计划
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 替换旧三层知识模型,构建 Dify 风格统一知识管理平台,作为 AI 客服的知识底座。
**Architecture:** 3 表新模型knowledge_bases → documents → chunks文档处理管线上传/URL/手动 → 解析 → 切片 → embedding混合意图路由关键词 → 向量 → LLM 兜底RAG 注入 AI 客服对话。
**Tech Stack:** Rust/Axum/SeaORM/pgvector后端React/Ant Design前端PostgreSQL + pgvector存储
**Design Spec:** `docs/superpowers/specs/2026-05-26-ai-knowledge-base-v2-design.md`
---
## Phase 分解
| Phase | 名称 | Tasks | 预估 | 依赖 |
|-------|------|-------|------|------|
| **Phase 0** | 数据库迁移 + Entity | 3 | 1-2h | 无 |
| **Phase 1** | 后端 Service + Handler知识库/文档 CRUD | 4 | 3-4h | Phase 0 |
| **Phase 2** | 文档处理管线(解析 + 切片 + embedding | 5 | 4-5h | Phase 0 |
| **Phase 3** | 混合意图路由 + 向量检索 | 3 | 2-3h | Phase 1 + 2 |
| **Phase 4** | 前端管理 UIDify 风格) | 4 | 3-4h | Phase 1 |
| **Phase 5** | AI 客服集成 + 旧数据迁移 | 4 | 2-3h | Phase 3 + 4 |
| **Phase 6** | 测试 + 验收 | 3 | 2h | Phase 5 |
**总计:** 26 Tasks / ~15-20h
## 文件结构清单
### 新增文件(后端)
```
crates/erp-ai/src/
├── entity/
│ ├── ai_knowledge_bases.rs # 新 Entity
│ ├── ai_knowledge_documents.rs # 新 Entity
│ └── ai_knowledge_chunks.rs # 新 Entity
├── knowledge/
│ ├── mod.rs # 改造:扩展 KnowledgeQuery
│ ├── pipeline.rs # 新增:文档处理管线
│ ├── parser.rs # 新增:格式解析器
│ ├── chunker.rs # 新增:智能切片
│ ├── hybrid_router.rs # 新增:混合意图路由
│ ├── vector_search.rs # 改造:适配新表
│ ├── structured_source.rs # 保留:过渡期兼容
│ └── vector_source.rs # 废弃:标记 deprecated
├── service/
│ └── knowledge.rs # 重写:新 CRUD
├── handler/
│ └── knowledge_handler.rs # 重写:新 API handler
└── module.rs # 改造:新路由注册
crates/erp-server/migration/src/
├── m20260526_000163_create_ai_knowledge_bases.rs
├── m20260526_000164_create_ai_knowledge_documents.rs
├── m20260526_000165_create_ai_knowledge_chunks.rs
└── m20260526_000166_migrate_old_knowledge_data.rs
```
### 新增文件(前端)
```
apps/web/src/
├── api/ai/
│ └── knowledge.ts # 重写:新 API client
├── pages/health/
│ ├── KnowledgeBase/
│ │ ├── index.tsx # 知识库列表页
│ │ ├── KnowledgeBaseDetail.tsx # 知识库详情页(文档管理)
│ │ ├── CreateKBModal.tsx # 创建知识库弹窗
│ │ ├── UploadDocument.tsx # 文档上传组件
│ │ ├── HitTestPanel.tsx # 命中测试面板
│ │ └── ChunkCard.tsx # 切片预览卡片
│ └── AiKnowledgePage.tsx # 废弃:被 KnowledgeBase/ 替代
```
### 修改文件
```
crates/erp-ai/Cargo.toml # 新增依赖
crates/erp-ai/src/state.rs # 改造:新 service 注入
crates/erp-ai/src/entity/mod.rs # 新增 Entity 注册
crates/erp-ai/src/service/chat_handler.rs # 改造RAG 注入
crates/erp-ai/src/agent/tools/search_medical_knowledge.rs # 改造:新检索
apps/web/src/App.tsx # 改造:新路由
```
## 详细 Tasks
---
## Chunk 1: Phase 0 — 数据库迁移 + Entity
### Task 1: 创建 ai_knowledge_bases 迁移
**Files:**
- Create: `crates/erp-server/migration/src/m20260526_000163_create_ai_knowledge_bases.rs`
- Modify: `crates/erp-server/migration/src/lib.rs`
- [ ] **Step 1: 创建迁移文件**
参考现有迁移 `m20260505_000121_create_ai_knowledge_references.rs` 的模式,创建 `ai_knowledge_bases` 表:
```rust
// m20260526_000163_create_ai_knowledge_bases.rs
use sea_orm_migration::prelude::*;
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str { "m20260526_000163_create_ai_knowledge_bases" }
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.create_table(
Table::create()
.table(Alias::new("ai_knowledge_bases"))
.if_not_exists()
// 标准字段
.col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key().default(Expr::cust("gen_random_uuid()")))
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
// 业务字段
.col(ColumnDef::new(Alias::new("name")).string_len(200).not_null())
.col(ColumnDef::new(Alias::new("kb_type")).string_len(50).not_null())
.col(ColumnDef::new(Alias::new("description")).text())
.col(ColumnDef::new(Alias::new("icon")).string_len(50))
.col(ColumnDef::new(Alias::new("chunk_strategy")).json_binary().not_null().default(Expr::cust("'{}'")))
.col(ColumnDef::new(Alias::new("intent_keywords")).json_binary().not_null().default(Expr::cust("'[]'")))
.col(ColumnDef::new(Alias::new("embedding_model")).string_len(100))
.col(ColumnDef::new(Alias::new("is_enabled")).boolean().not_null().default(true))
.col(ColumnDef::new(Alias::new("document_count")).integer().not_null().default(0))
.col(ColumnDef::new(Alias::new("chunk_count")).integer().not_null().default(0))
// 审计字段
.col(ColumnDef::new(Alias::new("created_at")).timestamp_with_time_zone().not_null().default(Expr::cust("now()")))
.col(ColumnDef::new(Alias::new("updated_at")).timestamp_with_time_zone().not_null().default(Expr::cust("now()")))
.col(ColumnDef::new(Alias::new("created_by")).uuid())
.col(ColumnDef::new(Alias::new("updated_by")).uuid())
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone())
.col(ColumnDef::new(Alias::new("version_lock")).integer().not_null().default(1))
.to_owned(),
).await?;
// 索引
manager.create_index(Index::create().name("idx_kb_tenant").table(Alias::new("ai_knowledge_bases")).col(Alias::new("tenant_id")).to_owned()).await?;
manager.create_index(Index::create().name("idx_kb_type").table(Alias::new("ai_knowledge_bases")).col(Alias::new("tenant_id")).col(Alias::new("kb_type")).to_owned()).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(Alias::new("ai_knowledge_bases")).to_owned()).await
}
}
```
- [ ] **Step 2: 注册迁移到 lib.rs**
`crates/erp-server/migration/src/lib.rs` 的迁移列表中添加:
```rust
Box::new(m20260526_000163_create_ai_knowledge_bases::Migration),
```
- [ ] **Step 3: 运行迁移验证**
```bash
cd crates/erp-server && cargo check
```
Expected: 编译通过,无错误
- [ ] **Step 4: 提交**
```bash
git add crates/erp-server/migration/src/m20260526_000163_create_ai_knowledge_bases.rs crates/erp-server/migration/src/lib.rs
git commit -m "feat(db): 创建 ai_knowledge_bases 表迁移"
```
### Task 2: 创建 ai_knowledge_documents + ai_knowledge_chunks 迁移
**Files:**
- Create: `crates/erp-server/migration/src/m20260526_000164_create_ai_knowledge_documents.rs`
- Create: `crates/erp-server/migration/src/m20260526_000165_create_ai_knowledge_chunks.rs`
- Modify: `crates/erp-server/migration/src/lib.rs`
- [ ] **Step 1: 创建 documents 迁移**
参照设计规格 §3.3,创建 `ai_knowledge_documents`包含id, tenant_id, knowledge_base_id (FK), title, source_type, file_path, file_type, file_size, source_url, raw_content, status, chunk_count, error_message, 审计字段。
FK 引用 `ai_knowledge_bases(id)`。索引:`idx_doc_kb`, `idx_doc_status`, `idx_doc_tenant`
- [ ] **Step 2: 创建 chunks 迁移**
参照设计规格 §3.4,创建 `ai_knowledge_chunks` 表。注意:
- `embedding` 列使用 `Expr::cust("vector(1536)")` 创建
- FK 引用 `ai_knowledge_documents(id)``ai_knowledge_bases(id)`
- HNSW 索引:`CREATE INDEX idx_chunk_embedding ON ai_knowledge_chunks USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64)` — 通过 `manager.get_connection().execute_unprepared()` 执行
- 普通 B-tree 索引idx_chunk_kb, idx_chunk_doc, idx_chunk_tenant, idx_chunk_hits
- 包含 updated_at, updated_by设计规格 review 修复后新增)
- [ ] **Step 3: 注册两个迁移到 lib.rs**
```rust
Box::new(m20260526_000164_create_ai_knowledge_documents::Migration),
Box::new(m20260526_000165_create_ai_knowledge_chunks::Migration),
```
- [ ] **Step 4: cargo check 验证**
- [ ] **Step 5: 提交**
```bash
git commit -m "feat(db): 创建 ai_knowledge_documents + ai_knowledge_chunks 表迁移"
```
### Task 3: 创建 Entity 文件 + Cargo.toml 依赖
**Files:**
- Create: `crates/erp-ai/src/entity/ai_knowledge_bases.rs`
- Create: `crates/erp-ai/src/entity/ai_knowledge_documents.rs`
- Create: `crates/erp-ai/src/entity/ai_knowledge_chunks.rs`
- Modify: `crates/erp-ai/src/entity/mod.rs`
- Modify: `crates/erp-ai/Cargo.toml`
- [ ] **Step 1: 在 Cargo.toml 添加新依赖**
```toml
pdf-extract = "0.7"
docx-rs = "0.4"
calamine = "0.25"
validator = { workspace = true, features = ["derive"] }
```
- [ ] **Step 2: 创建 3 个 Entity 文件**
参照现有 `ai_knowledge_references.rs` 模式。关键点:
- `embedding` 字段用 `#[sea_orm(ignore)]` 标记SeaORM 不支持 vector 类型)
- chunks 的 `hit_count` / `last_hit_at` 是统计字段
- 所有 Entity 包含完整审计字段
- [ ] **Step 3: 在 entity/mod.rs 注册新 Entity**
- [ ] **Step 4: cargo check 验证**
- [ ] **Step 5: 提交**
```bash
git commit -m "feat(ai): 新增知识库 V2 Entity + Cargo 依赖"
```
---
## Chunk 2: Phase 1 — 后端 Service + Handler
### Task 4: KnowledgeService 重写 — 知识库 CRUD
**Files:**
- Modify: `crates/erp-ai/src/service/knowledge.rs`
- [ ] **Step 1: 定义新 DTO 结构体**
`knowledge.rs` 顶部添加(旧的 DTO 保留但标记 `#[deprecated]`
```rust
#[derive(Debug, Serialize, Deserialize, Validate)]
pub struct CreateKnowledgeBaseReq {
#[validate(length(min = 1, max = 200))]
pub name: String,
#[validate(custom(function = "validate_kb_type"))]
pub kb_type: String,
#[validate(length(max = 2000))]
pub description: Option<String>,
#[validate(length(max = 50))]
pub icon: Option<String>,
pub intent_keywords: Option<Vec<String>>,
pub chunk_strategy: Option<serde_json::Value>,
}
#[derive(Debug, Serialize, Deserialize, Validate)]
pub struct UpdateKnowledgeBaseReq {
#[validate(length(min = 1, max = 200))]
pub name: Option<String>,
#[validate(length(max = 2000))]
pub description: Option<String>,
pub icon: Option<String>,
pub intent_keywords: Option<Vec<String>>,
pub chunk_strategy: Option<serde_json::Value>,
pub is_enabled: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ListKBQuery {
pub kb_type: Option<String>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
fn validate_kb_type(kb_type: &str) -> Result<(), validator::ValidationError> {
const VALID_TYPES: &[&str] = &["rule", "reference", "clinical_guide", "institution_info", "medical_process", "health_education", "faq"];
if VALID_TYPES.contains(&kb_type) { Ok(()) }
else { Err(validator::ValidationError::new("invalid_kb_type")) }
}
```
- [ ] **Step 2: 实现 KnowledgeService 新方法**
`KnowledgeService` impl 中添加:
- `list_bases(tenant_id, query) -> Vec<KBModel>` — 查询知识库列表,支持 kb_type 过滤
- `create_base(tenant_id, user_id, req) -> Uuid` — 创建知识库kb_type 对应默认 chunk_strategy
- `get_base(tenant_id, id) -> Option<KBModel>` — 单条查询
- `update_base(tenant_id, user_id, id, req)` — 更新,乐观锁 version_lock
- `delete_base(tenant_id, id)` — 软删除,同时软删除下级 documents
所有数据库操作使用 raw SQL`sea_orm::Statement`),因为 chunks 表含 vector 类型。
- [ ] **Step 3: cargo check 验证**
- [ ] **Step 4: 提交**
```bash
git commit -m "feat(ai): 知识库 V2 Service — 知识库 CRUD 方法"
```
### Task 5: KnowledgeService 重写 — 文档 CRUD + 文件上传
**Files:**
- Modify: `crates/erp-ai/src/service/knowledge.rs`
- [ ] **Step 1: 定义文档 DTO**
```rust
#[derive(Debug, Serialize, Deserialize, Validate)]
pub struct ImportUrlReq {
#[validate(url)]
#[validate(custom(function = "validate_no_ssrf"))]
pub url: String,
#[validate(length(max = 500))]
pub title: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Validate)]
pub struct CreateManualDocReq {
#[validate(length(min = 1, max = 500))]
pub title: String,
#[validate(length(min = 1))]
pub content: String,
}
```
SSRF 校验参考现有项目中的 `validate_no_ssrf` 实现erp-health 中的 URL 验证模式)。
- [ ] **Step 2: 实现文档 CRUD 方法**
- `list_documents(tenant_id, kb_id, query) -> Vec<DocModel>` — 按 kb_id 过滤
- `upload_document(tenant_id, kb_id, user_id, file) -> Uuid` — 保存文件到 uploads/,创建 document 记录 (status=pending)spawn 异步处理
- `import_url(tenant_id, kb_id, user_id, req) -> Uuid` — 创建 document 记录 (source_type=url)spawn 异步处理
- `create_manual(tenant_id, kb_id, user_id, req) -> Uuid` — 创建 document 记录 (source_type=manual)spawn 异步处理
- `get_document(tenant_id, doc_id) -> Option<DocModel>` — 含处理状态
- `delete_document(tenant_id, doc_id)` — 软删除 document + 删除对应 chunks
- `reprocess_document(tenant_id, doc_id)` — 删除旧 chunks → 重新走管线
- [ ] **Step 3: 实现切片查询方法**
- `list_chunks(tenant_id, doc_id, page, page_size) -> Vec<ChunkModel>` — 分页查询
- `update_chunk(tenant_id, chunk_id, content) — 更新切片内容
- `re_embed_chunk(tenant_id, chunk_id) — 重新 embedding 单个切片
- [ ] **Step 4: cargo check 验证**
- [ ] **Step 5: 提交**
```bash
git commit -m "feat(ai): 知识库 V2 Service — 文档/切片 CRUD + 文件上传"
```
### Task 6: Handler 重写 + 路由注册
**Files:**
- Modify: `crates/erp-ai/src/handler/knowledge_handler.rs`
- Modify: `crates/erp-ai/src/module.rs`
- Modify: `crates/erp-ai/src/state.rs`
- [ ] **Step 1: 重写 knowledge_handler.rs**
新的 handler 函数(旧的标记 `#[deprecated]`
**知识库管理5 个):**
- `list_bases` — GET分页列表
- `create_base` — POST调用 `req.validate()`
- `get_base` — GET单条详情
- `update_base` — PUT调用 `req.validate()`
- `delete_base` — DELETE软删除
**文档管理7 个):**
- `list_documents` — GET按 kb_id 过滤
- `upload_document` — POST multipart文件上传
- `import_url` — POST JSONURL 导入,调用 `req.validate()`
- `create_manual` — POST JSON手动录入调用 `req.validate()`
- `get_document` — GET详情含状态
- `delete_document` — DELETE
- `reprocess_document` — POST触发重新处理
**切片管理3 个):**
- `list_chunks` — GET分页
- `update_chunk` — PUT编辑内容
- `re_embed_chunk` — POST重新 embedding
**检索2 个):**
- `hit_test` — POST命中测试
- `search` — POST统一检索内部调用
所有 handler 使用 `require_permission` 守卫。multipart 上传使用 `axum::extract::Multipart`
- [ ] **Step 2: 更新 module.rs 路由注册**
替换旧的知识库路由为新的 RESTful 结构:
```rust
// 知识库管理
.kb_routes = Router::new()
.route("/", get(list_bases).post(create_base))
.route("/{id}", get(get_base).put(update_base).delete(delete_base))
// 文档管理
.route("/{kb_id}/documents", get(list_documents))
.route("/{kb_id}/documents/upload", post(upload_document))
.route("/{kb_id}/documents/url", post(import_url))
.route("/{kb_id}/documents/manual", post(create_manual))
.route("/{kb_id}/documents/{doc_id}", get(get_document).delete(delete_document))
.route("/{kb_id}/documents/{doc_id}/reprocess", post(reprocess_document))
// 切片管理
.route("/{kb_id}/documents/{doc_id}/chunks", get(list_chunks))
.route("/chunks/{chunk_id}", put(update_chunk))
.route("/chunks/{chunk_id}/re-embed", post(re_embed_chunk))
// 检索
.route("/{kb_id}/test", post(hit_test))
.route("/search", post(search))
```
旧路由保留 1 个月(重定向到新路由)。
- [ ] **Step 3: 更新 state.rs**
确保 `AiState` 中的 `knowledge: Arc<KnowledgeService>` 字段不变,构造函数适配新方法签名。
- [ ] **Step 4: cargo check 验证**
- [ ] **Step 5: 提交**
```bash
git commit -m "feat(ai): 知识库 V2 Handler + 路由注册"
```
### Task 7: 文件上传端点 + 静态文件服务
**Files:**
- Modify: `crates/erp-ai/src/handler/knowledge_handler.rs`upload_document 实现)
- Modify: `crates/erp-server/src/main.rs`(静态文件服务)
- [ ] **Step 1: 实现 upload_document handler**
```rust
async fn upload_document(
State(state): State<Arc<AiState>>,
user: AuthUser,
Path(kb_id): Path<Uuid>,
mut multipart: Multipart,
) -> impl IntoResponse {
// 1. require_permission
// 2. 从 multipart 取 field "file"
// 3. 验证 MIME 类型(白名单)+ 文件大小≤50MB+ sanitize 文件名
// 4. 保存到 uploads/knowledge/{tenant_id}/{uuid}.{ext}
// 5. 创建 document 记录 (status=pending)
// 6. spawn 异步处理管线
// 7. 返回 202 + document id
}
```
- [ ] **Step 2: 确保 uploads 目录有静态文件服务**
`erp-server/src/main.rs` 中确认 `/uploads` 路由已注册(应已存在)。
- [ ] **Step 3: cargo check + 手动测试上传**
```bash
curl -X POST http://localhost:3000/api/v1/ai/knowledge/bases/{kb_id}/documents/upload \
-H "Authorization: Bearer {token}" \
-F "file=@test.pdf"
```
Expected: 202 返回 document id
- [ ] **Step 4: 提交**
```bash
git commit -m "feat(ai): 知识库文件上传 + 静态文件服务"
```
---
## Chunk 3: Phase 2 — 文档处理管线
### Task 8: 格式解析器DocumentParser
**Files:**
- Create: `crates/erp-ai/src/knowledge/parser.rs`
- Modify: `crates/erp-ai/src/knowledge/mod.rs`
- [ ] **Step 1: 定义 ParsedDocument 结构体和 trait**
```rust
// parser.rs
#[derive(Debug, Clone)]
pub struct ParsedDocument {
pub title: String,
pub text: String, // 提取的纯文本
pub total_pages: Option<usize>,
pub metadata: serde_json::Value, // { format, pages, ... }
}
pub struct DocumentParser;
impl DocumentParser {
pub async fn parse_file(file_path: &str, file_type: &str) -> AiResult<ParsedDocument> {
match file_type {
"pdf" => Self::parse_pdf(file_path).await,
"docx" => Self::parse_docx(file_path).await,
"xlsx" | "csv" => Self::parse_spreadsheet(file_path, file_type).await,
"txt" => Self::parse_text(file_path).await,
"md" => Self::parse_markdown(file_path).await,
_ => Err(AiError::KnowledgeError(format!("Unsupported file type: {}", file_type))),
}
}
pub async fn parse_url(url: &str) -> AiResult<ParsedDocument> { /* ... */ }
}
```
- [ ] **Step 2: 实现 PDF 解析**
```rust
async fn parse_pdf(path: &str) -> AiResult<ParsedDocument> {
let bytes = tokio::fs::read(path).await.map_err(|e| AiError::KnowledgeError(e.to_string()))?;
let text = pdf_extract::extract_text_from_mem(&bytes)
.map_err(|e| AiError::KnowledgeError(format!("PDF parse failed: {}", e)))?;
// 检测纯图片 PDF提取文本长度 vs 页数比例
Ok(ParsedDocument { title: Path::new(path).file_stem().unwrap().to_string_lossy().into(), text, total_pages: None, metadata: json!({"format": "pdf"}) })
}
```
- [ ] **Step 3: 实现 Word / TXT / Markdown / Excel / URL 解析**
每个格式一个私有方法,参考设计规格 §4.2
- `parse_docx` — 使用 `docx-rs` 提取段落文本
- `parse_text` — 标准 `tokio::fs::read_to_string`
- `parse_markdown` — 同 text额外提取标题层级到 metadata
- `parse_spreadsheet` — 使用 `calamine`,表头+行数据 → 自然语言描述
- `parse_url` — 使用 `reqwest` + `scraper`,提取正文(去广告/导航)
- [ ] **Step 4: 在 mod.rs 中注册 parser 模块**
```rust
pub mod parser;
```
- [ ] **Step 5: cargo check 验证**
- [ ] **Step 6: 提交**
```bash
git commit -m "feat(ai): 文档解析器 — PDF/Word/Excel/TXT/MD/URL"
```
### Task 9: 智能切片器Chunker
**Files:**
- Create: `crates/erp-ai/src/knowledge/chunker.rs`
- [ ] **Step 1: 定义 ChunkStrategy 和 Chunk 结构**
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChunkStrategyConfig {
pub max_tokens: usize,
pub overlap: usize,
pub separator: String,
pub strategy: String, // per_item / qa_pair / paragraph / section / per_row
}
#[derive(Debug, Clone)]
pub struct RawChunk {
pub index: usize,
pub content: String,
pub token_count: usize,
pub metadata: serde_json::Value,
}
```
- [ ] **Step 2: 实现 5 种切片策略**
```rust
pub fn chunk_text(text: &str, doc_title: &str, config: &ChunkStrategyConfig) -> Vec<RawChunk> {
let prefixed_text = format!("[文档: {}] {}", doc_title, text);
match config.strategy.as_str() {
"per_item" => split_by_separator(&prefixed_text, &config.separator, config.max_tokens),
"qa_pair" => split_by_qa_pattern(&prefixed_text, config.max_tokens),
"paragraph" => split_with_overlap(&prefixed_text, &config.separator, config.max_tokens, config.overlap),
"section" => split_by_headings(&prefixed_text, config.max_tokens, config.overlap),
"per_row" => split_by_rows(&prefixed_text, config.max_tokens),
_ => split_with_overlap(&prefixed_text, "\n\n", config.max_tokens, config.overlap),
}
}
```
每个策略函数实现具体逻辑。token_count 使用简单的 `text.split_whitespace().count() / 0.75` 估算。
- [ ] **Step 3: 在 mod.rs 注册**
- [ ] **Step 4: cargo check**
- [ ] **Step 5: 提交**
```bash
git commit -m "feat(ai): 智能切片器 — 5 种策略"
```
### Task 10: 文档处理管线Pipeline
**Files:**
- Create: `crates/erp-ai/src/knowledge/pipeline.rs`
- [ ] **Step 1: 定义 Pipeline 结构和主流程**
```rust
pub struct DocumentPipeline {
db: DatabaseConnection,
embedding: Arc<EmbeddingService>,
}
impl DocumentPipeline {
pub fn new(db: DatabaseConnection, embedding: Arc<EmbeddingService>) -> Self { Self { db, embedding } }
pub async fn process(&self, doc_id: Uuid) {
// 1. 读取 document 记录,获取 file_path/source_url/raw_content
// 2. 更新 status = processing
// 3. 解析文档 → ParsedDocument
// 4. 读取 KB 的 chunk_strategy
// 5. 切片 → Vec<RawChunk>
// 6. 批量 embedding
// 7. 批量写入 ai_knowledge_chunks
// 8. 原子更新 document.status + kb 计数
// 任一步骤失败 → status=failed + error_message
if let Err(e) = self.process_inner(doc_id).await {
tracing::error!("Pipeline failed for doc {}: {}", doc_id, e);
self.mark_failed(doc_id, &e.to_string()).await;
}
}
}
```
- [ ] **Step 2: 实现 process_inner 核心逻辑**
```rust
async fn process_inner(&self, doc_id: Uuid) -> AiResult<()> {
// Step 1: 读取 document
let doc = self.get_document(doc_id).await?;
self.update_status(doc_id, "processing").await?;
// Step 2: 解析
let parsed = match doc.source_type.as_str() {
"upload" => DocumentParser::parse_file(&doc.file_path, &doc.file_type).await?,
"url" => DocumentParser::parse_url(&doc.source_url).await?,
"manual" => ParsedDocument { title: doc.title, text: doc.raw_content, total_pages: None, metadata: json!({}) },
_ => return Err(AiError::KnowledgeError("Unknown source type".into())),
};
// Step 3: 切片
let kb = self.get_kb(doc.knowledge_base_id).await?;
let strategy: ChunkStrategyConfig = serde_json::from_value(kb.chunk_strategy)?;
let chunks = chunker::chunk_text(&parsed.text, &doc.title, &strategy);
// Step 4: 批量 embedding + 写入
let mut embedded_count = 0;
for batch in chunks.chunks(20) {
let texts: Vec<&str> = batch.iter().map(|c| c.content.as_str()).collect();
match self.embedding.embed_batch(&texts).await {
Ok(embeddings) => {
self.batch_insert_chunks(doc_id, doc.knowledge_base_id, doc.tenant_id, batch, &embeddings).await?;
embedded_count += batch.len();
}
Err(e) => {
tracing::warn!("Embedding batch failed: {}, storing without embedding", e);
self.batch_insert_chunks_no_embedding(doc_id, doc.knowledge_base_id, doc.tenant_id, batch).await?;
}
}
}
// Step 5: 更新状态和计数(原子增量)
self.complete_document(doc_id, chunks.len(), embedded_count).await?;
self.increment_kb_counts(doc.knowledge_base_id, 1, chunks.len()).await?;
Ok(())
}
```
- [ ] **Step 3: 在 spawn 中调用 Pipeline**
`knowledge.rs``upload_document` / `import_url` / `create_manual` 中:
```rust
let pipeline = DocumentPipeline::new(self.db.clone(), self.embedding.clone());
tokio::spawn(async move {
pipeline.process(doc_id).await;
});
```
- [ ] **Step 4: cargo check**
- [ ] **Step 5: 提交**
```bash
git commit -m "feat(ai): 文档处理管线 — 异步解析+切片+embedding"
```
### Task 11: 命中测试 API
**Files:**
- Modify: `crates/erp-ai/src/knowledge/vector_search.rs`
- Modify: `crates/erp-ai/src/handler/knowledge_handler.rs`
- [ ] **Step 1: 改造 vector_search.rs 适配新表**
新增 `search_chunks` 方法,查询 `ai_knowledge_chunks` JOIN `ai_knowledge_bases` JOIN `ai_knowledge_documents`。所有参数使用参数化绑定,消除旧 `format!` SQL 拼接。
- [ ] **Step 2: 实现 hit_test handler**
接收 query + kb_id调用 `EmbeddingService::embed` + `search_chunks`,返回 top_k 切片 + 相似度 + 路由路径说明。
- [ ] **Step 3: cargo check + 手动测试**
- [ ] **Step 4: 提交**
```bash
git commit -m "feat(ai): 命中测试 API + 向量检索适配新表"
```
### Task 12: 管线端到端测试
**Files:**
- Create: `crates/erp-ai/tests/knowledge_pipeline_test.rs`
- [ ] **Step 1: 写端到端测试**
```rust
#[tokio::test]
async fn test_full_pipeline() {
// 1. 创建知识库 (kb_type=institution_info)
// 2. 创建测试 TXT 文件
// 3. 调用 upload_document
// 4. 等待处理完成(轮询 status
// 5. 验证 document.status = completed
// 6. 验证 chunks 数量 > 0
// 7. 验证 hit_test 能检索到切片
}
```
- [ ] **Step 2: 运行测试**
```bash
cargo test --package erp-ai knowledge_pipeline -- --nocapture
```
- [ ] **Step 3: 提交**
```bash
git commit -m "test(ai): 知识库管线端到端测试"
```
---
## Chunk 4: Phase 3 — 混合意图路由
### Task 13: HybridKnowledgeRouter 实现
**Files:**
- Create: `crates/erp-ai/src/knowledge/hybrid_router.rs`
- [ ] **Step 1: 定义 HybridKnowledgeRouter**
```rust
pub struct HybridKnowledgeRouter {
db: DatabaseConnection,
embedding: Arc<EmbeddingService>,
llm_provider: Arc<dyn LlmProvider>,
}
#[async_trait::async_trait]
impl KnowledgeSource for HybridKnowledgeRouter {
async fn get_context(&self, query: &KnowledgeQuery) -> AiResult<KnowledgeContext> {
let query_text = query.query_text.as_deref().unwrap_or("");
// Layer 0: 无知识库时返回空 context
let all_kbs = self.load_enabled_kbs(&query.tenant_id).await?;
if all_kbs.is_empty() {
return Ok(KnowledgeContext::empty());
}
// Layer 1: 关键词粗筛
let candidate_kb_ids = Self::keyword_match(query_text, &all_kbs);
// Layer 2: 向量检索
let results = self.vector_search(query_text, &candidate_kb_ids).await?;
// Layer 3: LLM 兜底(仅 top < 0.75 时触发)
let final_results = if results.first().map(|r| r.similarity).unwrap_or(0.0) < 0.75 {
self.llm_fallback(query_text, &results, &all_kbs).await?
} else {
results
};
self.build_context_and_track_hits(&final_results, &query.tenant_id).await
}
}
```
- [ ] **Step 2: 实现 keyword_match**
遍历所有 KB 的 `intent_keywords`,匹配 query 中的关键词。无匹配则返回全部 KB ID。
- [ ] **Step 3: 实现 vector_search**
调用改造后的 `vector_search.rs``search_chunks` 方法,参数化绑定。
- [ ] **Step 4: 实现 llm_fallback**
调用轻量 LLM优先 Ollama qwen3:4b做意图分类返回匹配的 KB ID 列表,重新调用 vector_search。
- [ ] **Step 5: 实现 build_context_and_track_hits**
组装 RAG context + 异步更新 chunk.hit_count。
- [ ] **Step 6: 在 mod.rs 注册**
- [ ] **Step 7: cargo check**
- [ ] **Step 8: 提交**
```bash
git commit -m "feat(ai): 混合意图路由 — 关键词+向量+LLM兜底"
```
### Task 14: KnowledgeQuery 扩展 + 旧 source 兼容
**Files:**
- Modify: `crates/erp-ai/src/knowledge/mod.rs`
- Modify: `crates/erp-ai/src/service/analysis.rs`
- [ ] **Step 1: 扩展 KnowledgeQuery**
```rust
pub struct KnowledgeQuery {
pub tenant_id: Uuid,
pub analysis_type: Option<String>, // 保留兼容
pub patient_context: Option<PatientSummary>,
pub query_text: Option<String>,
pub kb_type_filter: Option<String>, // 新增:按知识库类型过滤
}
```
- [ ] **Step 2: 在 analysis.rs 中使用 HybridKnowledgeRouter**
替换 `StructuredKnowledgeSource` + `VectorKnowledgeSource``HybridKnowledgeRouter``AnalysisService``knowledge_source` 字段类型不变(`Option<Arc<dyn KnowledgeSource>>`)。
- [ ] **Step 3: 旧 source 文件标记 deprecated**
`structured_source.rs``vector_source.rs` 顶部添加 `#[deprecated(note = "Use HybridKnowledgeRouter instead")]`
- [ ] **Step 4: cargo check**
- [ ] **Step 5: 提交**
```bash
git commit -m "refactor(ai): 知识源切换为 HybridKnowledgeRouter"
```
### Task 15: Agent Tool 适配
**Files:**
- Modify: `crates/erp-ai/src/agent/tools/search_medical_knowledge.rs`
- [ ] **Step 1: 改造 tool 使用新路由**
```rust
pub struct SearchMedicalKnowledgeTool {
router: Arc<HybridKnowledgeRouter>,
}
impl AgentTool for SearchMedicalKnowledgeTool {
async fn execute(&self, params: &Value) -> AiResult<Value> {
let query = params["query"].as_str().ok_or(...)?;
let kb_type = params["knowledge_base_type"].as_str(); // 新增可选参数
let knowledge_query = KnowledgeQuery {
query_text: Some(query.to_string()),
kb_type_filter: kb_type.map(String::from),
..Default::default()
};
let context = self.router.get_context(&knowledge_query).await?;
// 格式化返回结果
}
}
```
- [ ] **Step 2: 更新 sandbox.rs 注册**
- [ ] **Step 3: cargo check**
- [ ] **Step 4: 提交**
```bash
git commit -m "refactor(ai): Agent Tool 适配新知识库路由"
```
---
## Chunk 5: Phase 4 — 前端管理 UI
### Task 16: 前端 API client 重写
**Files:**
- Modify: `apps/web/src/api/ai/knowledge.ts`
- [ ] **Step 1: 定义新 TypeScript 接口**
```typescript
export interface KnowledgeBase {
id: string; name: string; kb_type: string;
description?: string; icon?: string;
chunk_strategy: ChunkStrategyConfig; intent_keywords: string[];
is_enabled: boolean; document_count: number; chunk_count: number;
created_at: string; updated_at: string;
}
export interface KnowledgeDocument {
id: string; knowledge_base_id: string; title: string;
source_type: 'upload' | 'url' | 'manual';
file_type?: string; file_size?: number; source_url?: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
chunk_count: number; error_message?: string;
created_at: string; updated_at: string;
}
export interface KnowledgeChunk {
id: string; document_id: string; knowledge_base_id: string;
chunk_index: number; content: string; token_count?: number;
metadata: Record<string, unknown>; hit_count: number; last_hit_at?: string;
}
export interface SearchResult {
chunk_id: string; content: string; similarity: number;
kb_name: string; doc_title: string;
metadata: Record<string, unknown>;
}
export interface ChunkStrategyConfig {
max_tokens: number; overlap: number; separator: string; strategy: string;
}
```
- [ ] **Step 2: 实现 knowledgeApi 对象**
覆盖所有新端点listBases, createBase, getBase, updateBase, deleteBase, listDocuments, uploadDocument, importUrl, createManual, getDocument, deleteDocument, reprocessDocument, listChunks, updateChunk, reEmbedChunk, hitTest。
- [ ] **Step 3: 提交**
```bash
git commit -m "feat(web): 知识库 V2 API client 重写"
```
### Task 17: 知识库列表页 + 创建弹窗
**Files:**
- Create: `apps/web/src/pages/health/KnowledgeBase/index.tsx`
- Create: `apps/web/src/pages/health/KnowledgeBase/CreateKBModal.tsx`
- [ ] **Step 1: 创建 KnowledgeBaseList 页面**
参照设计规格 §6.2卡片网格布局。KB_TYPE_CONFIG 映射表7 种类型对应图标+渐变色)。每张卡片显示:渐变色头部 + 图标 + 名称 + 类型标签 + 文档/切片数 + 状态。点击导航到详情页。
- [ ] **Step 2: 创建 CreateKBModal**
名称输入 + 类型选择(卡片式单选 7 种)+ 描述 + 意图关键词 TagInput + 切片策略折叠配置。
- [ ] **Step 3: 提交**
```bash
git commit -m "feat(web): 知识库列表页 + 创建弹窗"
```
### Task 18: 知识库详情页 + 文档上传
**Files:**
- Create: `apps/web/src/pages/health/KnowledgeBase/KnowledgeBaseDetail.tsx`
- Create: `apps/web/src/pages/health/KnowledgeBase/UploadDocument.tsx`
- [ ] **Step 1: 创建 KnowledgeBaseDetail**
面包屑 + 操作栏 + Tab 切换(文档列表|设置|命中测试。文档列表Ant Design Table + 处理进度轮询。设置 Tab编辑 KB 配置。
- [ ] **Step 2: 创建 UploadDocument**
三种上传入口 Tabs文件上传Draggeraccept=".pdf,.docx,.txt,.md,.xlsx,.csv"50MB+ URL 导入 + 手动录入TextArea
- [ ] **Step 3: 提交**
```bash
git commit -m "feat(web): 知识库详情页 + 文档上传"
```
### Task 19: 命中测试面板 + 路由注册
**Files:**
- Create: `apps/web/src/pages/health/KnowledgeBase/HitTestPanel.tsx`
- Create: `apps/web/src/pages/health/KnowledgeBase/ChunkCard.tsx`
- Modify: `apps/web/src/App.tsx`
- [ ] **Step 1: 创建 HitTestPanel**
输入框 + 测试按钮 + 路由路径展示 + 按相似度排序的 ChunkCard 列表。
- [ ] **Step 2: 创建 ChunkCard**
切片序号 + 来源 + 相似度标签(绿/黄/红)+ 可展开原文 + 编辑按钮。
- [ ] **Step 3: 更新 App.tsx 路由**
```tsx
<Route path="/health/ai-knowledge" element={<KnowledgeBaseList />} />
<Route path="/health/ai-knowledge/:kbId" element={<KnowledgeBaseDetail />} />
```
- [ ] **Step 4: pnpm build 验证**
- [ ] **Step 5: 提交**
```bash
git commit -m "feat(web): 命中测试 + 切片预览 + 路由注册"
```
---
## Chunk 6: Phase 5 — AI 客服集成 + 旧数据迁移
### Task 20: chat_handler RAG 注入
**Files:**
- Modify: `crates/erp-ai/src/service/chat_handler.rs`
- Modify: `crates/erp-ai/src/state.rs`
- [ ] **Step 1: 在 chat_handler 中注入 RAG**
消息发送前调用 `knowledge_router.get_context()`,非空时追加到 system prompt。失败时 warn 日志但不阻断对话。
- [ ] **Step 2: AiState 注入 HybridKnowledgeRouter**
新增 `knowledge_router: Option<Arc<HybridKnowledgeRouter>>` 字段。
- [ ] **Step 3: cargo check**
- [ ] **Step 4: 提交**
```bash
git commit -m "feat(ai): chat_handler RAG 注入 — AI 客服知识增强"
```
### Task 21: 前端引用渲染
**Files:**
- Modify: `apps/web/src/pages/health/ChatPage.tsx`
- [ ] **Step 1: 实现 parseReferences 函数**
正则解析 `[ref:xxx]` 标记,拆分为 text/ref 段。ref 段渲染为蓝色可点击 Tag下方 Collapse 显示来源详情。
- [ ] **Step 2: 提交**
```bash
git commit -m "feat(web): AI 回答引用溯源渲染"
```
### Task 22: 旧数据迁移脚本
**Files:**
- Create: `crates/erp-server/migration/src/m20260526_000166_migrate_old_knowledge_data.rs`
- Modify: `crates/erp-server/migration/src/lib.rs`
- [ ] **Step 1: 实现迁移(临时映射表 + 事务)**
参照设计规格 §8.3:临时映射表精确关联 → 按 analysis_type 创建 KB → 迁移 references/guides/rules → 清理。
- [ ] **Step 2: 注册 + cargo check + 手动运行验证**
- [ ] **Step 3: 提交**
```bash
git commit -m "feat(db): 旧知识库数据迁移到新模型"
```
### Task 23: 权限 + 菜单更新
**Files:**
- Create: `crates/erp-server/migration/src/m20260526_000167_update_knowledge_menu.rs`(如需要)
- [ ] **Step 1: 确认权限码复用**
`ai.knowledge.list``ai.knowledge.manage` 已覆盖,无需新增。确认菜单路径指向 `/health/ai-knowledge`
- [ ] **Step 2: 提交**
```bash
git commit -m "chore(db): 知识库菜单路由更新"
```
---
## Chunk 7: Phase 6 — 测试 + 验收
### Task 24: 后端单元/集成测试
**Files:**
- Create: `crates/erp-ai/tests/knowledge_v2_test.rs`
- [ ] **Step 1: KB CRUD 测试** — create → get → update → list → delete 完整流程
- [ ] **Step 2: 文档处理测试** — TXT + URL 两种源的处理验证
- [ ] **Step 3: 命中测试** — 上传后检索验证切片 + 相似度
- [ ] **Step 4: 混合路由测试** — 关键词命中 + 向量检索路径
- [ ] **Step 5: 运行 `cargo test --workspace`**
- [ ] **Step 6: 提交**
```bash
git commit -m "test(ai): 知识库 V2 单元/集成测试"
```
### Task 25: 前端 E2E 测试
**Files:**
- Create: `apps/web/e2e/knowledge-base.spec.ts`
- [ ] **Step 1: 编写 Playwright E2E** — 登录 → 创建 KB → 上传 → 命中测试 → 验证结果
- [ ] **Step 2: 运行 `pnpm exec playwright test knowledge-base`**
- [ ] **Step 3: 提交**
```bash
git commit -m "test(web): 知识库 V2 E2E 测试"
```
### Task 26: 端到端验收 + wiki 更新
**Files:**
- Modify: `wiki/index.md`
- [ ] **Step 1: 手动验收** — 创建KB → 上传PDF/Word/Excel → URL导入 → 手动录入 → 命中测试 → AI客服对话验证引用
- [ ] **Step 2: 更新 wiki 关键数字** — 表数+3, 路由数, 测试数
- [ ] **Step 3: 旧文件清理** — 旧 AiKnowledgePage.tsx 标记 deprecated
- [ ] **Step 4: 最终验证**`cargo check` + `cargo test` + `pnpm build`
- [ ] **Step 5: 提交 + 推送**
```bash
git push
```
---
## 验收标准
| # | 标准 | 验证方式 |
|---|------|----------|
| 1 | 创建知识库 + 上传 PDF/Word/Excel处理成功 | 手动测试 |
| 2 | URL 导入能抓取并切片 | 手动测试 |
| 3 | 命中测试返回正确切片 + 相似度 | 手动测试 |
| 4 | AI 客服引用知识库,显示 [ref:xxx] | 手动测试 |
| 5 | 旧数据成功迁移 | DB 查询 |
| 6 | cargo check 0 警告 | CI |
| 7 | cargo test 全部通过 | CI |
| 8 | pnpm build 通过 | CI |
| 9 | 所有新端点有权限守卫 | 代码审查 |