feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -160,6 +161,125 @@ describe('Point 07: AI 知识库分块切片与召回沙盒 (modules/ai knowledg
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
在新工单中引用
屏蔽一个用户