# SOP 测试单元工厂设计 · TDD + SDD 三驱动融合,推送飞书 - **状态**:已批准(Approved) - **日期**:2026-07-07 - **作者**:brainstorming session - **影响范围**:全 6 后端模块(POC 阶段聚焦 `rag`);产出物推送至飞书知识库 `7659208807054117857` 的"产品"目录 --- ## 一、目标与背景 把 BuildFlow"现在的功能"拆解为**可执行、可追踪、可复制的最小 SOP 测试单元**,通过三位一体的工作流落地到飞书(文档 + 任务联动),实现端到端场景的标准化验证。 ### 三驱动定义(与用户对齐) | 驱动 | 全称 | 在本项目中的具体含义 | |------|------|----------------------| | **TDD-文档** | Technical Design Document | 技术设计文档:架构、接口契约、数据模型、时序图、边界条件 | | **TDD-测试** | Test-Driven Development | 测试驱动开发:先写 xUnit 测试(红)→ 实现(绿)→ 重构 | | **SDD** | Skill-Driven Development | 技能驱动开发:复用项目已有的 `.agents/skills/rag-coding-api` 编码规范(MediatR CQRS、测试规范、模块边界) | ### 现状(已通过代码探索确认) | 维度 | 现状 | 与本设计的关系 | |------|------|----------------| | 测试体系 | 8 个 xUnit 测试项目,1472 源文件 vs 92 测试文件,FluentAssertions + EF InMemory | 成熟,但全部是单元/Handler 级,**无端到端 SOP 测试** | | RAG 测试 | `BuildFlow.Rag.Tests` 仅 7 个文件,覆盖 sanitizer/parser/catalog/validator | **关键缺口**:无 ProcessDocumentCommandHandler、SendMessageCommandHandler、RagQueryCommandHandler、RAGStreamEndpoint 的集成测试 | | 文档 | 仅 scripting/x6-workflow/CI-CD 三份设计文档,散落各子工程 | **无统一 PRD/SDD/SOP 体系** | | 技能规范 | `.agents/skills/rag-coding-api`(27KB)已强制 MediatR CQRS、禁 service bridge、测试规范 | SDD 的载体已就绪,本设计直接复用 | | 飞书 | 已有知识库 `7659208807054117857`,含"服务器配置""项目开发指南"两个根节点 | 本设计在其下新建"产品"目录承载 SOP 产出 | --- ## 二、核心决策(已与用户确认) | # | 决策点 | 选择 | 理由 | |---|--------|------|------| | 1 | 功能范围 | 全部 6 后端模块 | rag/file/im/platform/workflow/scripting 全覆盖 | | 2 | 推进策略 | POC 先行:RAG 跑通再复制 | 风险低、模式可复制、工作量可控 | | 3 | 测试单元粒度 | 端到端场景 | 用户视角完整流程(非原子用例) | | 4 | 飞书产出形式 | 飞书文档 + 飞书任务联动 | 文档沉淀设计,任务追踪执行 | | 5 | RAG 场景 | A=知识库 RAG 问答全链路 + B=多轮对话流式问答 | 互补覆盖 RAG 两条主干(检索 vs 对话) | | 6 | pgvector 隔离 | Stub 先行 + Testcontainers 集成测试补充 | POC 用 Stub 快速跑通,后续补真实召回验证 | | 7 | 实现顺序 | 文档先行 → 测试后补 | 文档引导实现,飞书文档作为单一事实来源 | | 8 | 模板化 | 8 段式文档骨架 + 5 任务结构 | Phase 2 可填模板复制到其余 5 模块 | --- ## 三、SOP 测试单元工厂架构 ### 3.1 生产流水线 ``` [SDD 规范] [TDD 设计] [TDD 实现] rag-coding-api skill → 技术设计文档 → xUnit 测试先行 (MediatR CQRS / (架构/接口/数据模型/ (红色测试 → 测试规范) 时序/边界) 绿色实现 → 重构) │ │ │ └──────────────────────┴───────────────────────┘ │ ▼ ┌─────────────────────────┐ │ 最小 SOP 测试单元 │ │ (端到端场景) │ └────────────┬────────────┘ │ ┌─────────────┴─────────────┐ ▼ ▼ ┌─────────────────┐ ┌────────────────────┐ │ 飞书文档 │ │ 飞书任务 │ │ (SOP + 设计) │◄──────►│ (检查项可勾选) │ │ 评审/沉淀 │ deep │ 执行/追踪 │ │ │ link │ │ └─────────────────┘ └────────────────────┘ ``` ### 3.2 飞书知识库目录结构 ``` 📚 Wiki 空间 7659208807054117857 ├── 📄 服务器配置 (已存在) ├── 📄 项目开发指南 (已存在) └── 📁 产品/ ← 已创建 node_token=StjYwzzsSisql1ki2oqc5KFEnZd ├── 📄 产品概览 - BuildFlow 模块地图 └── 📁 SOP 测试单元/ ├── 📁 RAG 模块/ │ ├── 📄 [SOP-RAG-01] 知识库 RAG 问答全链路 │ └── 📄 [SOP-RAG-02] 多轮对话流式问答 ├── 📁 File 模块/ (Phase 2) ├── 📁 IM 模块/ (Phase 2) ├── 📁 Platform 模块/ (Phase 2) ├── 📁 Workflow 模块/ (Phase 2) └── 📁 Scripting 模块/ (Phase 2) ``` --- ## 四、SOP 测试单元标准模板 每个 SOP 测试单元(无论 RAG 还是后续模块)都遵循同一个 8 段式文档骨架 + 5 任务结构。这是 Phase 2 能快速复制的关键。 ### 4.1 飞书文档骨架(8 段式) ``` # [SOP-XX] 场景名称 ## 模块: rag | 场景类型: 端到端 | 状态: 草稿/评审/通过 ## 1. 用户故事与价值 作为...我希望...以便... ## 2. 前置条件 (SDD 规范约束) - 遵守 rag-coding-api skill: MediatR CQRS / 测试规范 - 依赖的外部服务及隔离方式 (Stub/Testcontainer) ## 3. 技术设计 (TDD 文档部分) 3.1 架构图 (mermaid 时序图) 3.2 涉及的 Command/Query/Handler 清单 3.3 数据模型变更 (实体/字段) 3.4 关键接口契约 (输入/输出) ## 4. SOP 执行步骤 (端到端) Step 1...N: 每步含 API 调用 / Handler / 预期结果 ## 5. 验收标准 (Definition of Done) ☐ 可勾选的检查项列表 ## 6. 测试设计 (TDD 测试部分) 6.1 测试文件位置与命名 6.2 Stub/Fixture 设计 6.3 测试用例清单 (Given/When/Then) ## 7. 执行结果记录 - 测试运行命令与输出 - 覆盖率 - 已知限制 ## 8. 变更日志 ``` ### 4.2 飞书任务结构(每个 SOP 单元 = 1 父任务 + 5 子任务) ``` 父任务: [SOP-XX] 场景名称 - 端到端验证 ├── ☐ T1: 编写 TDD 技术设计文档 (description 含飞书文档 deep link) ├── ☐ T2: 编写 xUnit 测试用例 (红色阶段) ├── ☐ T3: 实现/对齐 Handler 逻辑 (绿色阶段) ├── ☐ T4: 跑通端到端测试,记录结果 (链接到文档§7) └── ☐ T5: 评审 + 标记场景状态为「通过」 ``` 任务描述嵌入飞书文档 deep link,实现**文档 ↔ 任务双向联动**。 --- ## 五、RAG 模块 POC 场景详细设计 ### 5.1 场景 A:[SOP-RAG-01] 知识库 RAG 问答全链路 **用户故事**:作为知识工作者,我上传一份文档到知识库,提问后能获得基于该文档的准确回答和引用来源。 **覆盖链路**:建 KB → 上传文档 → 触发处理流水线(提取/分块/向量化/入库)→ RAG 检索问答。 **SOP 步骤(端到端)**: | Step | API 调用 | MediatR | 预期结果 | |------|----------|---------|----------| | 1 | `POST /knowledge-bases` | CreateKnowledgeBaseCommand | KB 创建,配 RetrievalMode=Hybrid/TopK=5/阈值=0.7 | | 2 | `POST /knowledge-bases/{id}/documents` (multipart) | UploadDocumentCommand | Document 落库 Status=Pending | | 3 | `POST /documents/{id}/process` | ProcessDocumentCommand | 4 阶段流水线:提取→分块→向量化→入库,Status=Processed | | 4 | `POST /rag/query` | RagQueryCommandHandler | 返回 {answer, sources[]},sources 非空 | **涉及 Handler**:CreateKnowledgeBaseCommandHandler、UploadDocumentCommandHandler、ProcessDocumentCommandHandler、RagQueryCommandHandler、IRagRetrievalService、IAIChatAgent。 **验收标准(DoD)**: - ☐ 文档 Status 从 Pending → Processing → Processed - ☐ DocumentChunk 数量 = 预期分块数,Content 非空 - ☐ /rag/query 返回 sources[] 非空且 DocumentId 匹配 - ☐ answer 包含 LLM 响应文本 - ☐ 全程无未捕获异常 **测试隔离策略**: | 外部依赖 | 处理方式 | |----------|----------| | pgvector | **POC**:Stub `IRagRetrievalService` 返回预设 sources;**补充**:Testcontainers + 真实 PG+pgvector 集成测试 | | LM Studio | Stub `IAIChatAgent`(RunAsync 返回固定文本)+ Stub `IEmbeddingService`(返回固定 768 维向量) | | 对象存储 | Stub `IFileStorageClient`(复用 `StubFileStorageClient` 模式) | | 文档提取 | Stub `IDocumentExtractorFactory` + `IDocumentExtractor`(返回固定文本) | **测试文件**:`tests/BuildFlow.Rag.Tests/RagQAEndToEndTests.cs` --- ### 5.1.1 分块策略详解(ChunkingMode 6 种模式) 分块在 `ProcessDocumentCommand` 阶段 2,由 `ITextChunker.Chunk(extracted, mode, size, overlap, separator)` 完成(实现 `Infrastructure/AI/TextChunker.cs`,设计参考 Dify): | 模式 | 值 | 切分逻辑 | |------|---|---------| | **General** | 0(默认) | 段落感知:先按 `\n\n` 切段,超长段按句子标点二切,overlap 取最后 1-2 句 | | **Heading** | 1 | 按 Markdown H1-H6 切,维护 headingStack,生成 HeadingPath 如 "第三章 > 3.1 概述" | | **ParentChild** | 2 | 父块=整段(上下文,最大 chunkSize),子块=段落再切(chunkSize/3,精确匹配),**父子都向量化** | | **Separator** | 3 | 按自定义分隔符(默认 `\n\n---\n\n`)切分,超长用 General 二切 | | **ByPage** | 4 | PDF 每页/PPT 每张一块,页过长用 General 二切,子块共享 PageNumber | | **Token** | 5 | 按 token 估算:charBudget = chunkSize × 1.5(注释称"中文=2/英文=1"但实际用固定 1.5 系数,**存在实现差异**) | **ParentChild 回填机制**:先 SaveChanges 拿父块 Guid → 用 parentIdMap[Index→Guid] 反查 → 写入子块 ParentChunkId(两轮 SaveChanges)。 **参数优先级**:模式 `doc.ChunkingModeOverride ?? kb.ChunkingMode`(文档级覆盖优先);ChunkSize/ChunkOverlap/Separator 全部来自 kb,默认 500/50/null。 ### 5.1.2 向量化策略详解 - **接口**:`IEmbeddingService.EmbedAsync(单条)` + `EmbedBatchAsync(批量)`,实现 `Infrastructure/AI/EmbeddingService.cs`,OpenAI 兼容协议 - **批处理**:`EmbeddingBatchSize = 64`,≤64 一次调用,否则按 64 步长切片按原顺序拼接(顺序必须与输入一致) - **向量维度 = 768**(非 384/1536):默认模型 `embeddinggemma-300m` 输出 768 维,DB 列 `vector(768)`,**换模型必须同步改迁移** - **写入方式**:EF 忽略 Embedding 列(`DocumentChunkConfiguration.Ignore`),逐块 raw SQL `UPDATE document_chunks SET embedding='[...]'::vector WHERE "Id"=...`(N 条 SQL,无批量优化) - **TokenCount 粗估**:`Content.Length / 4`,非真实 tokenizer ### 5.1.3 召回与重排算法详解 `IRagRetrievalService.RetrieveAsync(kb, question)` → `List`,4 步流水线: 1. **粗召回** `RecallAsync`:SQL `LIMIT RetrievalTopK`(默认 20) 2. **阈值过滤**:`VectorScore >= SimilarityThreshold`(默认 0.3),纯 Keyword 跳过 3. **内存重排**:按 mode 计算 finalScore,`Take(ContextTopK)`(默认 5) 4. **父块回填** `EnrichWithContextAsync`(当前空壳,见差异清单) **Vector 召回 SQL**(pgvector 余弦距离): ```sql SELECT ..., 1 - (dc.embedding <=> '{q}'::vector) AS similarity FROM document_chunks dc JOIN documents d ON ... WHERE d."KnowledgeBaseId" = '{kbId}' AND dc.embedding IS NOT NULL AND dc."ParentChunkId" IS NULL -- 只召回父块/独立块 ORDER BY dc.embedding <=> '{q}'::vector LIMIT 20 ``` **Keyword 召回**:`ExtractKeywords` 分词(英文 `[a-zA-Z]+`≥2,中文 ≤4 整体/>4 做 2-4 字 N-gram),每个关键词 `CASE WHEN Content ILIKE @kwN THEN 1 ELSE 0` 相加得命中数。 **Hybrid 重排加权**:`finalScore = VectorScore × VectorWeight + keywordScore × (1-VectorWeight)`,默认 VectorWeight=0.7(70% 向量 + 30% 关键词)。 ### 5.1.4 设计意图 vs 当前实现差异清单(技术债务) 代码探索发现 4 处差异,POC 用 Stub 会绕过,但 Testcontainers 集成测试必须覆盖: | # | 差异点 | 设计意图 | 当前实现 | |---|--------|---------|---------| | 1 | **RerankStrategy 未读取** | 枚举 None/Keyword/LLM 可切换重排 | 重排分支依据是 RetrievalMode,该字段从未被读取,LLM 重排未实现 | | 2 | **Hybrid 非双路融合** | 向量召回 + 关键词召回融合(RRF/加权) | Hybrid 只执行向量召回 SQL,关键词仅在内存重排加权参与,未做关键词路独立召回 | | 3 | **父块回填空壳** | 召回子块→回填父块获完整上下文 | `EnrichWithContextAsync` 无 DB 查询,ContextContent=Content;SQL `ParentChunkId IS NULL` 召回的是父块而非子块 | | 4 | **表名不一致(潜在 bug)** | EF 表 `b_document_chunks` | raw SQL 写 `document_chunks`(无 `b_` 前缀),可能导致向量写入失败 | --- ### 5.2 场景 B:[SOP-RAG-02] 多轮对话流式问答 **用户故事**:作为用户,我与 AI 进行多轮对话,AI 能记住上下文并以流式方式逐字返回。 **覆盖链路**:建会话 → 流式提问 → 多轮历史拼装 → SSE 协议 → 缓存一致性。 **SOP 步骤(端到端)**: | Step | API 调用 | 处理路径 | 预期结果 | |------|----------|----------|----------| | 1 | `POST /chat/conversations` | CreateConversationCommand | 会话创建(不绑 KB) | | 2 | `POST /chat/conversations/{id}/stream` | StreamMessageEndpoint(直连) | SSE token 流,event:done 返回 messageId | | 3 | `POST /chat/conversations/{id}/stream`(第二轮) | 同上 | 历史拼装含第一轮 user+assistant | | 4 | 校验 | IChatMessageCache ↔ DB | cache 与 DB ChatMessage 一致 | **涉及 Handler/Endpoint**:CreateConversationCommandHandler、StreamMessageEndpoint、IAIChatAgent.RunStreamingAsync、IChatMessageCache、AiChatOutputSanitizer。 **验收标准(DoD)**: - ☐ 两轮 SSE 均以 event:done 结束并返回 messageId - ☐ 第二轮请求时,历史拼装包含第一轮的 user+assistant 消息 - ☐ AiChatOutputSanitizer 生效(去开场白/think 标签) - ☐ Redis cache 与 DB 消息记录一致 - ☐ 会话可删除(DELETE 返回成功) **测试隔离策略**: | 外部依赖 | 处理方式 | |----------|----------| | LM Studio | Stub `IAIChatAgent.RunStreamingAsync`(yield 固定 token 序列) | | Redis | Stub `IChatMessageCache`(内存字典模拟)或用真实 Redis | **测试文件**:`tests/BuildFlow.Rag.Tests/ChatStreamEndToEndTests.cs` --- ## 六、Stub/Fixture 设计(共享测试基础设施) 复用并扩展现有 `PreviewDocumentChunksQueryHandlerTests` 的 Stub 模式,新增以下共享测试基础设施(放 `tests/BuildFlow.Rag.Tests/Stub/`): | Stub/Fixture | 实现接口 | 用途 | |--------------|----------|------| | `StubFileStorageClient` | `IFileStorageClient` | 提供文件流(内存 Stream),不调真实对象存储 | | `StubDocumentExtractor` | `IDocumentExtractor` | 返回固定提取文本 | | `StubDocumentExtractorFactory` | `IDocumentExtractorFactory` | 按扩展名分发 Stub | | `StubEmbeddingService` | `IEmbeddingService` | 返回固定维度向量(如 384 维) | | `StubAIChatAgent` | `IAIChatAgent` | RunAsync 返回固定文本;RunStreamingAsync yield 固定 token | | `StubRagRetrievalService` | `IRagRetrievalService` | 返回预设 sources 列表(避开 pgvector) | | `StubChatMessageCache` | `IChatMessageCache` | 内存字典模拟 Redis cache | **测试 Host 搭建模式**:参考 `BuildFlow.IM.Integration.Tests` 的 `ImServerFixture`/`ImServerCollection` 模式,搭建 `RagEndToEndFixture`(注册 Stub 到 DI 容器 + EF InMemory + WebApplicationFactory)。 --- ## 七、Phase 划分与里程碑 ### Phase 1:RAG POC(本次实现) | 里程碑 | 交付物 | 验收 | |--------|--------|------| | M1: 飞书落地 | 产品目录 + 产品概览 + RAG 2 份 SOP 文档 + 对应飞书任务 | 文档可访问,任务可勾选 | | M2: 测试实现 | 2 个 E2E 测试文件 + 7 个 Stub 类 + RagEndToEndFixture | `dotnet test` 全绿 | | M3: POC 验收 | 测试输出回填飞书文档§7,场景状态标记「通过」 | 模板固化,可复制 | ### Phase 2:复制到其余 5 模块(后续) 按 file → im → platform → workflow → scripting 顺序,每模块选 1-2 个端到端场景,套用 Phase 1 固化的模板。 --- ## 八、风险与缓解 | 风险 | 缓解 | |------|------| | pgvector 无法 InMemory 测试 | POC 用 Stub RetrievalService;补充 Testcontainers 集成测试(需 Docker) | | SSE 流式测试断言复杂 | 用 `HttpContent.ReadAsStreamAsync` 逐行解析 SSE,参考现有 `OpenAiCompatibleChatStreamParserTests` | | StreamMessageEndpoint 直连不经 MediatR | 测试用 WebApplicationFactory 走真实 HTTP,而非直接调 Endpoint | | Phase 2 模块差异大 | 8 段式模板的可变部分集中在§3 技术设计,其余段落复用 | --- ## 九、未来扩展(YAGNI - 本次不做) - Web 前端端到端 SOP(Playwright,现有体系已支持) - Portal/Client 端 SOP - 飞书任务自动同步测试结果(CI/CD webhook) - SOP 执行自动化(CLI 一键跑全部场景) --- ## 附录 A:关键文件索引 | 路径 | 作用 | |------|------| | `build-flow.api/src/modules/rag/Application/` | Command/Query/Handler 全清单 | | `build-flow.api/src/modules/rag/Domain/Entities/` | KnowledgeBase/Document/DocumentChunk/Conversation/ChatMessage | | `build-flow.api/src/modules/rag/Infrastructure/AI/RagRetrievalService.cs` | 向量检索核心 | | `build-flow.api/src/BuildFlow.Host/Endpoints/Rag/` | HTTP 路由 | | `build-flow.api/tests/BuildFlow.Rag.Tests/` | 现有测试 + 本次新增 E2E | | `.agents/skills/rag-coding-api/SKILL.md` | SDD 规范来源 | | 飞书 Wiki `7659208807054117857` / 产品节点 `StjYwzzsSisql1ki2oqc5KFEnZd` | SOP 产出落点 |