import * as fs from 'fs'; import * as path from 'path'; import { CONTROLLER_KEY, getClassMetadata } from '@midwayjs/core'; import { BaseController, CurdOption } from '@cool-midway/core'; import { AiKnowledgeService } from '../../../src/modules/ai/service/knowledge'; import { AdminAiKnowledgeController, adminAiKnowledgeCurdOption, } from '../../../src/modules/ai/controller/admin/knowledge'; import { KnowledgeChunk } from '../../../src/comm/types/omni'; interface CoolControllerMeta { prefix?: string; curdOption?: CurdOption; } async function waitCoolMeta( target: new (...args: never[]) => unknown ): Promise { const started = Date.now(); while (Date.now() - started < 2000) { const meta = getClassMetadata(CONTROLLER_KEY, target) as | CoolControllerMeta | undefined; if (meta?.curdOption) { return meta; } await new Promise(resolve => setImmediate(resolve)); } throw new Error(`${target.name} 的 CoolController 元数据未就绪`); } function serviceApiMethods(apis: CurdOption['serviceApis'] = []): string[] { return apis.map(api => (typeof api === 'string' ? api : api.method)); } describe('Point 07: AI 知识库分块切片与召回沙盒 (modules/ai knowledge sandbox)', () => { let service: AiKnowledgeService; beforeEach(() => { service = new AiKnowledgeService(); }); describe('1. splitTextToChunks 切片算法边界', () => { it('空文本或空白文本应返回空数组', () => { expect(service.splitTextToChunks('')).toEqual([]); expect(service.splitTextToChunks(' \n\t ')).toEqual([]); }); it('小文本边界:文本长度 <= chunkSize 应返回单一切片', () => { const text = 'OmniCard 全球数字名片'; const chunks = service.splitTextToChunks(text, 50, 10); expect(chunks).toHaveLength(1); expect(chunks[0].content).toBe(text); expect(chunks[0].index).toBe(0); expect(chunks[0].length).toBe(text.length); }); it('长文本滑动切片:验证切片数、重叠字符与末尾覆盖', () => { // 构造长度为 45 的文本 const text = '1234567890abcdefghij1234567890abcdefghij12345'; const chunkSize = 20; const overlap = 5; const chunks = service.splitTextToChunks(text, chunkSize, overlap); // step = 20 - 5 = 15: // chunk 0: [0, 20] // chunk 1: [15, 35] // chunk 2: [30, 45] (末尾到达 45) expect(chunks).toHaveLength(3); expect(chunks[0].content).toBe(text.slice(0, 20)); expect(chunks[1].content).toBe(text.slice(15, 35)); expect(chunks[2].content).toBe(text.slice(30, 45)); // 验证重叠:chunk 0 的末尾 5 字符应等于 chunk 1 的开头 5 字符 expect(chunks[0].content.slice(-5)).toBe(chunks[1].content.slice(0, 5)); // 验证末尾完整覆盖 expect(chunks[chunks.length - 1].content.endsWith('12345')).toBe(true); }); it('重叠截断边界:overlap >= chunkSize 时应自动纠正步长,防止死循环', () => { const text = '这是一段用来测试重叠过大保护机制的测试长文本'; // overlap 设为 30,大于 chunkSize 20 const chunks = service.splitTextToChunks(text, 20, 30); expect(chunks.length).toBeGreaterThan(1); expect(chunks[0].length).toBeLessThanOrEqual(20); }); it('未指定 chunkSize 与 overlap 时应具有合理默认值', () => { const longText = 'A'.repeat(500); const chunks = service.splitTextToChunks(longText); expect(chunks.length).toBeGreaterThan(1); expect(chunks[0].content.length).toBe(200); }); }); describe('2. simulateRetrieval 召回算法与排序计算', () => { const mockChunks: KnowledgeChunk[] = [ { index: 0, content: 'OmniCard 提供 NFC 智能实体卡,碰一碰即可传输联系方式与微官网。', length: 37, }, { index: 1, content: '雷达系统支持智能 CRM 访客轨迹追踪、停留时长与意向度自动评分。', length: 33, }, { index: 2, content: 'AI 知识库提供文档分块切片预览与 RAG 检索增强沙盒调试能力。', length: 34, }, { index: 3, content: 'Bento Grid 便当盒美学布局,响应式适配全球多语言及 RTL 界面。', length: 34, }, ]; it('空 query 或空 chunks 应返回空数组', () => { expect(service.simulateRetrieval('', mockChunks)).toEqual([]); expect(service.simulateRetrieval('NFC', [])).toEqual([]); }); it('应按匹配置信度降序排序,完全匹配/高频命中排第一', () => { const query = 'NFC 实体卡 碰一碰'; const results = service.simulateRetrieval(query, mockChunks, 3); expect(results.length).toBeGreaterThan(0); // 第一条应该是 index 为 0 的 NFC 相关切片 expect(results[0].index).toBe(0); expect(results[0].score).toBeGreaterThan(0.5); expect(results[0].score).toBeLessThanOrEqual(1.0); }); it('应支持 topK 截断返回数量', () => { const query = '系统 智能 适配'; const results = service.simulateRetrieval(query, mockChunks, 2); expect(results.length).toBeLessThanOrEqual(2); }); it('应支持 threshold 阈值过滤低相关度切片', () => { const query = 'RAG 沙盒调试'; // 只有切片 2 高度匹配,设置高阈值过滤其他低分或无关切片 const results = service.simulateRetrieval(query, mockChunks, 4, 0.4); expect(results).toHaveLength(1); expect(results[0].index).toBe(2); expect(results[0].content).toContain('RAG 检索增强沙盒调试'); }); it('应兼容接收纯字符串数组 chunks', () => { const stringChunks = [ '国际多语言支持英文、中文、阿拉伯语', '国内网络环境配置说明', ]; const results = service.simulateRetrieval('阿拉伯语 国际多语言', stringChunks, 2); expect(results.length).toBe(1); expect(results[0].content).toContain('阿拉伯语'); expect(results[0].score).toBeGreaterThan(0); }); }); describe('3. AdminAiKnowledgeController 与 /admin/ai/knowledge/sandbox-test 接口', () => { const controllerFile = path.join( __dirname, '../../../src/modules/ai/controller/admin/knowledge.ts' ); let meta: CoolControllerMeta; let option: CurdOption; beforeAll(async () => { meta = await waitCoolMeta(AdminAiKnowledgeController); option = meta.curdOption as CurdOption; }); it('控制器文件与路由前缀符合规范', () => { expect(fs.existsSync(controllerFile)).toBe(true); expect(meta.prefix).toBe('/admin/ai/knowledge'); expect(AdminAiKnowledgeController.prototype).toBeInstanceOf(BaseController); }); it('serviceApis 应注册 sandboxTest,且控制器包含 sandboxTest 方法', () => { const methods = serviceApiMethods(option.serviceApis); expect(methods).toContain('sandboxTest'); expect(typeof (AdminAiKnowledgeController.prototype as unknown as Record).sandboxTest).toBe('function'); }); it('AiKnowledgeService.sandboxTest 应能执行切片并召回', async () => { const result = await service.sandboxTest({ query: 'NFC 实体卡', content: 'OmniCard NFC 智能实体名片碰一碰极速交换。另有在线智能顾问与访客雷达。', chunkSize: 30, overlap: 5, topK: 2, }); expect(result.totalChunks).toBeGreaterThan(0); expect(result.chunks.length).toBe(result.totalChunks); expect(result.recallList.length).toBeGreaterThan(0); expect(result.recallList[0].score).toBeGreaterThan(0); }); }); });