补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
324 行
12 KiB
TypeScript
324 行
12 KiB
TypeScript
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
import axios from 'axios';
|
||
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<CoolControllerMeta> {
|
||
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<void>(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('2.1 retrieve 统一召回入口 (keyword / embedding)', () => {
|
||
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,
|
||
},
|
||
];
|
||
|
||
const envSnapshot = {
|
||
key: process.env.EMBEDDING_API_KEY,
|
||
url: process.env.EMBEDDING_BASE_URL,
|
||
};
|
||
|
||
afterEach(() => {
|
||
jest.restoreAllMocks();
|
||
if (envSnapshot.key === undefined) {
|
||
delete process.env.EMBEDDING_API_KEY;
|
||
} else {
|
||
process.env.EMBEDDING_API_KEY = envSnapshot.key;
|
||
}
|
||
if (envSnapshot.url === undefined) {
|
||
delete process.env.EMBEDDING_BASE_URL;
|
||
} else {
|
||
process.env.EMBEDDING_BASE_URL = envSnapshot.url;
|
||
}
|
||
});
|
||
|
||
it('无 EMBEDDING_API_KEY 时 mode 为 keyword,排序与 score 与 simulateRetrieval 一致', async () => {
|
||
delete process.env.EMBEDDING_API_KEY;
|
||
delete process.env.EMBEDDING_BASE_URL;
|
||
|
||
const query = 'NFC 实体卡 碰一碰';
|
||
const baseline = service.simulateRetrieval(query, mockChunks, 3);
|
||
const results = await service.retrieve(query, mockChunks, { topK: 3 });
|
||
|
||
expect(results).toHaveLength(baseline.length);
|
||
expect(results.every(item => item.mode === 'keyword')).toBe(true);
|
||
expect(results.map(item => item.index)).toEqual(
|
||
baseline.map(item => item.index)
|
||
);
|
||
expect(results.map(item => item.score)).toEqual(
|
||
baseline.map(item => item.score)
|
||
);
|
||
// 词重叠分数不得伪装成 embedding 相似度字段
|
||
results.forEach(item => {
|
||
expect(item).not.toHaveProperty('similarity');
|
||
expect(item).not.toHaveProperty('cosine');
|
||
});
|
||
});
|
||
|
||
it('有 EMBEDDING_API_KEY 但无 EMBEDDING_BASE_URL 时应抛明确错误,不静默回退', async () => {
|
||
process.env.EMBEDDING_API_KEY = 'test-embedding-key';
|
||
delete process.env.EMBEDDING_BASE_URL;
|
||
|
||
await expect(
|
||
service.retrieve('NFC', mockChunks, { topK: 2 })
|
||
).rejects.toThrow(/EMBEDDING_BASE_URL/);
|
||
});
|
||
|
||
it('有 key 与 URL 时应调用 axios,并按向量相似度排序返回 mode=embedding', async () => {
|
||
process.env.EMBEDDING_API_KEY = 'test-embedding-key';
|
||
process.env.EMBEDDING_BASE_URL = 'https://example.test/v1/embeddings';
|
||
|
||
const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
|
||
data: {
|
||
data: [
|
||
{ index: 0, embedding: [1, 0, 0] }, // query
|
||
{ index: 1, embedding: [0.1, 0.9, 0] }, // chunk0 — 低相似
|
||
{ index: 2, embedding: [0.99, 0.01, 0] }, // chunk1 — 高相似
|
||
{ index: 3, embedding: [0, 1, 0] }, // chunk2
|
||
{ index: 4, embedding: [0.2, 0.8, 0] }, // chunk3
|
||
],
|
||
},
|
||
});
|
||
|
||
const results = await service.retrieve('访客雷达 CRM', mockChunks, {
|
||
topK: 2,
|
||
});
|
||
|
||
expect(postSpy).toHaveBeenCalledTimes(1);
|
||
expect(postSpy.mock.calls[0][0]).toBe(
|
||
'https://example.test/v1/embeddings'
|
||
);
|
||
expect(results).toHaveLength(2);
|
||
expect(results.every(item => item.mode === 'embedding')).toBe(true);
|
||
// chunk1 向量最接近 query,应排第一
|
||
expect(results[0].index).toBe(1);
|
||
expect(results[0].score).toBeGreaterThan(results[1].score ?? 0);
|
||
expect(results[0].score).toBeLessThanOrEqual(1);
|
||
});
|
||
|
||
it('embedding 请求失败时应抛错,不回退 keyword', async () => {
|
||
process.env.EMBEDDING_API_KEY = 'test-embedding-key';
|
||
process.env.EMBEDDING_BASE_URL = 'https://example.test/v1/embeddings';
|
||
|
||
jest.spyOn(axios, 'post').mockRejectedValueOnce(new Error('network down'));
|
||
|
||
await expect(
|
||
service.retrieve('NFC', mockChunks, { topK: 1 })
|
||
).rejects.toThrow(/network down|embedding|EMBEDDING/i);
|
||
});
|
||
});
|
||
|
||
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<string, unknown>).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);
|
||
});
|
||
});
|
||
});
|