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)
1185 lines
39 KiB
Markdown
1185 lines
39 KiB
Markdown
# 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** | 前端管理 UI(Dify 风格) | 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 JSON,URL 导入,调用 `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:文件上传(Dragger,accept=".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 | 所有新端点有权限守卫 | 代码审查 |
|