From dda8be907976e52d1925a14354e30d8d6c0b8b03 Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 26 May 2026 23:00:46 +0800 Subject: [PATCH] =?UTF-8?q?docs(ai):=20AI=20=E7=9F=A5=E8=AF=86=E5=BA=93=20?= =?UTF-8?q?V2=20=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92=20=E2=80=94=207=20Pha?= =?UTF-8?q?se=20/=2026=20Tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-ai-knowledge-base-v2-plan.md | 1184 +++++++++++++++++ 1 file changed, 1184 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-26-ai-knowledge-base-v2-plan.md diff --git a/docs/superpowers/plans/2026-05-26-ai-knowledge-base-v2-plan.md b/docs/superpowers/plans/2026-05-26-ai-knowledge-base-v2-plan.md new file mode 100644 index 0000000..57c2a89 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-ai-knowledge-base-v2-plan.md @@ -0,0 +1,1184 @@ +# 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, + #[validate(length(max = 50))] + pub icon: Option, + pub intent_keywords: Option>, + pub chunk_strategy: Option, +} + +#[derive(Debug, Serialize, Deserialize, Validate)] +pub struct UpdateKnowledgeBaseReq { + #[validate(length(min = 1, max = 200))] + pub name: Option, + #[validate(length(max = 2000))] + pub description: Option, + pub icon: Option, + pub intent_keywords: Option>, + pub chunk_strategy: Option, + pub is_enabled: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ListKBQuery { + pub kb_type: Option, + pub page: Option, + pub page_size: Option, +} + +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` — 查询知识库列表,支持 kb_type 过滤 +- `create_base(tenant_id, user_id, req) -> Uuid` — 创建知识库,kb_type 对应默认 chunk_strategy +- `get_base(tenant_id, id) -> Option` — 单条查询 +- `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, +} + +#[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` — 按 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` — 含处理状态 +- `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` — 分页查询 +- `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` 字段不变,构造函数适配新方法签名。 + +- [ ] **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>, + user: AuthUser, + Path(kb_id): Path, + 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, + pub metadata: serde_json::Value, // { format, pages, ... } +} + +pub struct DocumentParser; + +impl DocumentParser { + pub async fn parse_file(file_path: &str, file_type: &str) -> AiResult { + 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 { /* ... */ } +} +``` + +- [ ] **Step 2: 实现 PDF 解析** + +```rust +async fn parse_pdf(path: &str) -> AiResult { + 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 { + 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, +} + +impl DocumentPipeline { + pub fn new(db: DatabaseConnection, embedding: Arc) -> 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 + // 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, + llm_provider: Arc, +} + +#[async_trait::async_trait] +impl KnowledgeSource for HybridKnowledgeRouter { + async fn get_context(&self, query: &KnowledgeQuery) -> AiResult { + 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, // 保留兼容 + pub patient_context: Option, + pub query_text: Option, + pub kb_type_filter: Option, // 新增:按知识库类型过滤 +} +``` + +- [ ] **Step 2: 在 analysis.rs 中使用 HybridKnowledgeRouter** + +替换 `StructuredKnowledgeSource` + `VectorKnowledgeSource` 为 `HybridKnowledgeRouter`。`AnalysisService` 的 `knowledge_source` 字段类型不变(`Option>`)。 + +- [ ] **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, +} + +impl AgentTool for SearchMedicalKnowledgeTool { + async fn execute(&self, params: &Value) -> AiResult { + 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; 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; +} + +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 +} /> +} /> +``` + +- [ ] **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>` 字段。 + +- [ ] **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 | 所有新端点有权限守卫 | 代码审查 |