feat(asl): Complete Deep Research V2.0 core development
Backend: - Add SSE streaming client (unifuncsSseClient) replacing async polling - Add paragraph-based reasoning parser with mergeConsecutiveThinking - Add requirement expansion service (DeepSeek-V3 PICOS+MeSH) - Add Word export service with Pandoc, inline hyperlinks, reference link expansion - Add deep research V2 worker with 2s log flush and Chinese source prompt - Add 5 curated data sources config (PubMed/ClinicalTrials/Cochrane/CNKI/MedJournals) - Add 4 API endpoints (generate-requirement/tasks/task-status/export-word) - Update Prisma schema with 6 new V2.0 fields on AslResearchTask - Add DB migration for V2.0 fields - Simplify ASL_DEEP_RESEARCH_EXPANSION prompt (remove strategy section) Frontend: - Add waterfall-flow DeepResearchPage (phase 0-4 progressive reveal) - Add LandingView, SetupPanel, StrategyConfirm, AgentTerminal, ResultsView - Add react-markdown + remark-gfm for report rendering - Add custom link component showing visible URLs after references - Add useDeepResearchTask polling hook - Add deep research TypeScript types Tests: - Add E2E test, smoke test, and Chinese data source test scripts Docs: - Update ASL module status (v2.0 - core features complete) - Update system status (v6.1 - ASL V2.0 milestone) - Update Unifuncs DeepSearch API guide (v2.0 - SSE mode + Chinese source results) - Update module auth specification (test script guidelines) - Update V2.0 development plan Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 中文数据源专项测试
|
||||
*
|
||||
* 只搜索 CNKI + 中华医学期刊网,验证 Unifuncs API 能否检索中文文献。
|
||||
*
|
||||
* 运行: npx tsx src/modules/asl/__tests__/deep-research-chinese-sources.ts
|
||||
*/
|
||||
|
||||
const UNIFUNCS_BASE_URL = 'https://api.unifuncs.com/deepsearch/v1';
|
||||
const UNIFUNCS_API_KEY = process.env.UNIFUNCS_API_KEY || 'sk-2fNwqUH73elGq0aDKJEM4ReqP7Ry0iqHo4OXyidDe2WpQ9XQ';
|
||||
|
||||
const CHINESE_SOURCES = [
|
||||
'https://www.cnki.net/',
|
||||
'https://medjournals.cn/',
|
||||
];
|
||||
|
||||
const TEST_QUERIES = [
|
||||
'2型糖尿病患者SGLT2抑制剂的肾脏保护作用',
|
||||
'非小细胞肺癌免疫治疗的中国临床研究进展',
|
||||
];
|
||||
|
||||
async function runTest(query: string, domainScope: string[]) {
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log(`查询: ${query}`);
|
||||
console.log(`数据源: ${domainScope.join(', ')}`);
|
||||
console.log('='.repeat(80));
|
||||
|
||||
// --- Test 1: SSE 流式模式 ---
|
||||
console.log('\n--- SSE 流式模式测试 ---');
|
||||
try {
|
||||
const payload = {
|
||||
model: 's2',
|
||||
messages: [{ role: 'user', content: query }],
|
||||
stream: true,
|
||||
introduction: '你是一名专业的中国临床研究文献检索专家,擅长从中国学术数据库中检索中文医学文献。请使用中文关键词进行检索。',
|
||||
max_depth: 10,
|
||||
domain_scope: domainScope,
|
||||
reference_style: 'link',
|
||||
output_prompt: '请使用中文输出。列出所有检索到的文献,包含标题、作者、期刊、年份和链接。如果文献是中文的,请保留中文信息。',
|
||||
};
|
||||
|
||||
const response = await fetch(`${UNIFUNCS_BASE_URL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${UNIFUNCS_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
console.error(`❌ SSE 请求失败 HTTP ${response.status}: ${text}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`✅ SSE 连接成功 (HTTP ${response.status})`);
|
||||
|
||||
const reader = (response.body as any).getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let reasoningContent = '';
|
||||
let content = '';
|
||||
let chunkCount = 0;
|
||||
let reasoningChunks = 0;
|
||||
let contentChunks = 0;
|
||||
const startTime = Date.now();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith(':')) continue;
|
||||
|
||||
if (trimmed.startsWith('data: ')) {
|
||||
const data = trimmed.slice(6);
|
||||
if (data === '[DONE]') {
|
||||
console.log('\n✅ 流式传输完成 [DONE]');
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
const delta = json.choices?.[0]?.delta;
|
||||
chunkCount++;
|
||||
|
||||
if (delta?.reasoning_content) {
|
||||
reasoningContent += delta.reasoning_content;
|
||||
reasoningChunks++;
|
||||
if (reasoningChunks % 50 === 0) {
|
||||
process.stdout.write(`\r reasoning chunks: ${reasoningChunks}, content chunks: ${contentChunks}`);
|
||||
}
|
||||
} else if (delta?.content) {
|
||||
content += delta.content;
|
||||
contentChunks++;
|
||||
if (contentChunks % 20 === 0) {
|
||||
process.stdout.write(`\r reasoning chunks: ${reasoningChunks}, content chunks: ${contentChunks}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip unparseable
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reader.releaseLock();
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
|
||||
console.log(`\n\n📊 统计:`);
|
||||
console.log(` 耗时: ${elapsed}s`);
|
||||
console.log(` 总 chunks: ${chunkCount}`);
|
||||
console.log(` reasoning chunks: ${reasoningChunks} (${reasoningContent.length} chars)`);
|
||||
console.log(` content chunks: ${contentChunks} (${content.length} chars)`);
|
||||
|
||||
// 分析结果
|
||||
console.log(`\n📝 Reasoning 内容(前 500 字):`);
|
||||
console.log(reasoningContent.slice(0, 500));
|
||||
|
||||
console.log(`\n📝 Content 结果(前 1000 字):`);
|
||||
console.log(content.slice(0, 1000));
|
||||
|
||||
// 检查是否包含中文内容
|
||||
const chineseCharCount = (content.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||||
const hasChineseLinks = content.includes('cnki.net') || content.includes('medjournals.cn');
|
||||
const hasPubMedLinks = content.includes('pubmed.ncbi.nlm.nih.gov');
|
||||
|
||||
console.log(`\n🔍 中文分析:`);
|
||||
console.log(` 中文字符数: ${chineseCharCount}`);
|
||||
console.log(` 包含 CNKI 链接: ${hasChineseLinks ? '✅ 是' : '❌ 否'}`);
|
||||
console.log(` 包含 PubMed 链接: ${hasPubMedLinks ? '⚠️ 是(非中文源)' : '✅ 否'}`);
|
||||
|
||||
// 统计所有 URL
|
||||
const urls = content.match(/https?:\/\/[^\s)]+/g) || [];
|
||||
console.log(` 结果中的链接 (共 ${urls.length} 个):`);
|
||||
const domainCounts: Record<string, number> = {};
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const domain = new URL(url).hostname;
|
||||
domainCounts[domain] = (domainCounts[domain] || 0) + 1;
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
for (const [domain, count] of Object.entries(domainCounts).sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${domain}: ${count} 个`);
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(`❌ SSE 测试失败: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🔬 中文数据源专项测试');
|
||||
console.log(`API Key: ${UNIFUNCS_API_KEY.slice(0, 10)}...`);
|
||||
console.log(`测试数据源: ${CHINESE_SOURCES.join(', ')}`);
|
||||
|
||||
for (const query of TEST_QUERIES) {
|
||||
await runTest(query, CHINESE_SOURCES);
|
||||
}
|
||||
|
||||
// 额外测试: 同时包含中英文数据源
|
||||
console.log('\n\n' + '🔬'.repeat(20));
|
||||
console.log('附加测试: 中英文混合数据源');
|
||||
await runTest(
|
||||
'他汀类药物在心血管疾病一级预防中的最新RCT证据',
|
||||
['https://pubmed.ncbi.nlm.nih.gov/', ...CHINESE_SOURCES]
|
||||
);
|
||||
|
||||
console.log('\n\n✅ 所有测试完成');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
345
backend/src/modules/asl/__tests__/deep-research-v2-e2e.ts
Normal file
345
backend/src/modules/asl/__tests__/deep-research-v2-e2e.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Deep Research V2.0 — 端到端集成测试
|
||||
*
|
||||
* 测试全流程:
|
||||
* Step 1: 登录获取 Token
|
||||
* Step 2: 获取数据源列表
|
||||
* Step 3: 需求扩写(LLM 调用)
|
||||
* Step 4: 查看 draft 任务
|
||||
* Step 5: HITL 确认 → 启动执行
|
||||
* Step 6: 轮询任务直到 completed/failed
|
||||
* Step 7: 验证结果完整性
|
||||
* Step 8: Word 导出(可选,需要 Pandoc 微服务)
|
||||
* Step 9: 清理测试数据
|
||||
*
|
||||
* 运行方式:
|
||||
* npx tsx src/modules/asl/__tests__/deep-research-v2-e2e.ts
|
||||
*
|
||||
* 前置条件:
|
||||
* - 后端服务运行在 localhost:3001
|
||||
* - PostgreSQL 运行中
|
||||
* - UNIFUNCS_API_KEY 环境变量已设置
|
||||
* - (可选)Python 微服务运行在 localhost:8000(Word 导出)
|
||||
*
|
||||
* 预计耗时:2-5 分钟(取决于 Unifuncs 搜索深度)
|
||||
* 预计成本:约 ¥0.05-0.2(LLM 需求扩写 + Unifuncs DeepSearch)
|
||||
*/
|
||||
|
||||
// ─── 配置 ───────────────────────────────────────
|
||||
|
||||
const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3001';
|
||||
const API_PREFIX = `${BASE_URL}/api/v1`;
|
||||
|
||||
const TEST_PHONE = process.env.TEST_PHONE || '13800000001';
|
||||
const TEST_PASSWORD = process.env.TEST_PASSWORD || '123456';
|
||||
|
||||
const TEST_QUERY = '他汀类药物在心血管疾病一级预防中的最新 RCT 证据';
|
||||
|
||||
const MAX_POLL_ATTEMPTS = 60;
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
const SKIP_WORD_EXPORT = process.env.SKIP_WORD_EXPORT === 'true';
|
||||
const SKIP_CLEANUP = process.env.SKIP_CLEANUP === 'true';
|
||||
|
||||
// ─── 工具函数 ───────────────────────────────────
|
||||
|
||||
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
let authToken = '';
|
||||
|
||||
async function api(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<{ status: number; data: any }> {
|
||||
const url = `${API_PREFIX}${path}`;
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
let data: any;
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
data = await res.json();
|
||||
} else if (contentType.includes('application/vnd.openxmlformats')) {
|
||||
const buf = await res.arrayBuffer();
|
||||
data = { _binary: true, byteLength: buf.byteLength };
|
||||
} else {
|
||||
data = await res.text();
|
||||
}
|
||||
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
function assert(condition: boolean, message: string) {
|
||||
if (!condition) {
|
||||
throw new Error(`❌ 断言失败: ${message}`);
|
||||
}
|
||||
console.log(` ✅ ${message}`);
|
||||
}
|
||||
|
||||
function printDivider(title: string) {
|
||||
console.log(`\n${'═'.repeat(70)}`);
|
||||
console.log(` ${title}`);
|
||||
console.log(`${'═'.repeat(70)}\n`);
|
||||
}
|
||||
|
||||
// ─── 测试流程 ───────────────────────────────────
|
||||
|
||||
async function runE2E() {
|
||||
console.log('🧪 Deep Research V2.0 端到端集成测试');
|
||||
console.log(`⏰ 时间: ${new Date().toLocaleString('zh-CN')}`);
|
||||
console.log(`📍 后端: ${BASE_URL}`);
|
||||
console.log(`📝 查询: ${TEST_QUERY}`);
|
||||
console.log('');
|
||||
|
||||
let taskId: string | null = null;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// ═══════════ Step 1: 登录 ═══════════
|
||||
printDivider('Step 1: 登录获取 Token');
|
||||
|
||||
const loginRes = await api('/auth/login/password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: TEST_PHONE, password: TEST_PASSWORD }),
|
||||
});
|
||||
|
||||
if (loginRes.status !== 200 || !loginRes.data.success) {
|
||||
console.log('⚠️ 登录返回:', JSON.stringify(loginRes.data, null, 2));
|
||||
throw new Error(
|
||||
`登录失败 (HTTP ${loginRes.status}): ${loginRes.data.error || loginRes.data.message || '未知错误'}\n` +
|
||||
`请确认测试账号存在:phone=${TEST_PHONE}, password=${TEST_PASSWORD}\n` +
|
||||
`或通过环境变量指定:TEST_PHONE=xxx TEST_PASSWORD=yyy`
|
||||
);
|
||||
}
|
||||
|
||||
authToken = loginRes.data.data.tokens?.accessToken || loginRes.data.data.accessToken || loginRes.data.data.token;
|
||||
assert(!!authToken, `获取到 Token (${authToken.slice(0, 20)}...)`);
|
||||
|
||||
// ═══════════ Step 2: 获取数据源列表 ═══════════
|
||||
printDivider('Step 2: 获取数据源列表');
|
||||
|
||||
const sourcesRes = await api('/asl/research/data-sources');
|
||||
assert(sourcesRes.status === 200, `HTTP 200 (实际: ${sourcesRes.status})`);
|
||||
assert(sourcesRes.data.success === true, 'success=true');
|
||||
|
||||
const sources = sourcesRes.data.data;
|
||||
assert(Array.isArray(sources), `返回数组 (${sources.length} 个数据源)`);
|
||||
assert(sources.length >= 3, `至少 3 个数据源`);
|
||||
|
||||
console.log('\n 数据源列表:');
|
||||
sources.forEach((s: any) => {
|
||||
console.log(` ${s.defaultChecked ? '☑' : '☐'} [${s.category}] ${s.label} → ${s.domainScope}`);
|
||||
});
|
||||
|
||||
const defaultIds = sources.filter((s: any) => s.defaultChecked).map((s: any) => s.domainScope);
|
||||
console.log(`\n 默认选中: ${defaultIds.join(', ')}`);
|
||||
|
||||
// ═══════════ Step 3: 需求扩写 ═══════════
|
||||
printDivider('Step 3: 需求扩写(LLM 调用)');
|
||||
console.log(' ⏳ 调用 LLM 生成检索指令书,请稍候...\n');
|
||||
|
||||
const expandStart = Date.now();
|
||||
const expandRes = await api('/asl/research/generate-requirement', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
originalQuery: TEST_QUERY,
|
||||
targetSources: defaultIds,
|
||||
filters: { yearRange: '近5年', targetCount: '约20篇' },
|
||||
}),
|
||||
});
|
||||
|
||||
const expandMs = Date.now() - expandStart;
|
||||
assert(expandRes.status === 200, `HTTP 200 (实际: ${expandRes.status})`);
|
||||
assert(expandRes.data.success === true, 'success=true');
|
||||
|
||||
const expandData = expandRes.data.data;
|
||||
taskId = expandData.taskId;
|
||||
assert(!!taskId, `创建 draft 任务: ${taskId}`);
|
||||
assert(!!expandData.generatedRequirement, `生成检索指令书 (${expandData.generatedRequirement.length} 字)`);
|
||||
assert(!!expandData.intentSummary, 'PICOS 结构化摘要已生成');
|
||||
|
||||
console.log(`\n ⏱️ 耗时: ${(expandMs / 1000).toFixed(1)}s`);
|
||||
console.log(`\n 📋 PICOS 摘要:`);
|
||||
const summary = expandData.intentSummary;
|
||||
console.log(` 目标: ${summary.objective || '—'}`);
|
||||
console.log(` P: ${summary.population || '—'}`);
|
||||
console.log(` I: ${summary.intervention || '—'}`);
|
||||
console.log(` C: ${summary.comparison || '—'}`);
|
||||
console.log(` O: ${summary.outcome || '—'}`);
|
||||
console.log(` 研究设计: ${(summary.studyDesign || []).join(', ')}`);
|
||||
console.log(` MeSH: ${(summary.meshTerms || []).join(', ')}`);
|
||||
|
||||
console.log(`\n 📝 指令书预览 (前 200 字):`);
|
||||
console.log(` ${expandData.generatedRequirement.slice(0, 200).replace(/\n/g, '\n ')}...`);
|
||||
|
||||
// ═══════════ Step 4: 查看 draft 任务 ═══════════
|
||||
printDivider('Step 4: 查看 draft 状态任务');
|
||||
|
||||
const draftRes = await api(`/asl/research/tasks/${taskId}`);
|
||||
assert(draftRes.status === 200, `HTTP 200`);
|
||||
assert(draftRes.data.data.status === 'draft', `状态为 draft`);
|
||||
assert(draftRes.data.data.query === TEST_QUERY, `query 匹配`);
|
||||
|
||||
// ═══════════ Step 5: HITL 确认 → 启动 ═══════════
|
||||
printDivider('Step 5: HITL 确认 → 启动执行');
|
||||
|
||||
const confirmedReq = expandData.generatedRequirement;
|
||||
|
||||
const executeRes = await api(`/asl/research/tasks/${taskId}/execute`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ confirmedRequirement: confirmedReq }),
|
||||
});
|
||||
|
||||
assert(executeRes.status === 200, `HTTP 200 (实际: ${executeRes.status})`);
|
||||
assert(executeRes.data.success === true, '任务已入队');
|
||||
console.log(' 🚀 Deep Research 任务已启动!');
|
||||
|
||||
// ═══════════ Step 6: 轮询 ═══════════
|
||||
printDivider('Step 6: 轮询任务进度');
|
||||
|
||||
let lastLogCount = 0;
|
||||
let finalStatus = '';
|
||||
|
||||
for (let i = 1; i <= MAX_POLL_ATTEMPTS; i++) {
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
|
||||
const pollRes = await api(`/asl/research/tasks/${taskId}`);
|
||||
const task = pollRes.data.data;
|
||||
|
||||
const logs = task.executionLogs || [];
|
||||
const newLogs = logs.slice(lastLogCount);
|
||||
|
||||
if (newLogs.length > 0) {
|
||||
newLogs.forEach((log: any) => {
|
||||
const icon: Record<string, string> = {
|
||||
thinking: '💭', searching: '🔍', reading: '📖',
|
||||
analyzing: '🧪', summary: '📊', info: 'ℹ️',
|
||||
};
|
||||
console.log(` ${icon[log.type] || '•'} [${log.title}] ${log.text.slice(0, 80)}`);
|
||||
});
|
||||
lastLogCount = logs.length;
|
||||
}
|
||||
|
||||
console.log(` [${i}/${MAX_POLL_ATTEMPTS}] status=${task.status} | logs=${logs.length} | elapsed=${((Date.now() - startTime) / 1000).toFixed(0)}s`);
|
||||
|
||||
if (task.status === 'completed' || task.status === 'failed') {
|
||||
finalStatus = task.status;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert(finalStatus === 'completed', `任务完成 (实际: ${finalStatus || 'timeout'})`);
|
||||
|
||||
// ═══════════ Step 7: 验证结果 ═══════════
|
||||
printDivider('Step 7: 验证结果完整性');
|
||||
|
||||
const resultRes = await api(`/asl/research/tasks/${taskId}`);
|
||||
const result = resultRes.data.data;
|
||||
|
||||
assert(result.status === 'completed', '状态: completed');
|
||||
assert(!!result.synthesisReport, `AI 综合报告已生成 (${(result.synthesisReport || '').length} 字)`);
|
||||
assert(!!result.completedAt, `completedAt 已设置`);
|
||||
|
||||
const hasResultList = Array.isArray(result.resultList) && result.resultList.length > 0;
|
||||
if (hasResultList) {
|
||||
console.log(` ✅ 结构化文献列表: ${result.resultList.length} 篇`);
|
||||
console.log('\n 📚 文献样例 (前 3 篇):');
|
||||
result.resultList.slice(0, 3).forEach((item: any, i: number) => {
|
||||
console.log(` ${i + 1}. ${item.title || '(无标题)'}`);
|
||||
if (item.authors) console.log(` 作者: ${item.authors}`);
|
||||
if (item.journal) console.log(` 期刊: ${item.journal}`);
|
||||
if (item.pmid) console.log(` PMID: ${item.pmid}`);
|
||||
});
|
||||
} else {
|
||||
console.log(' ⚠️ 结构化文献列表为空(降级到报告展示模式)');
|
||||
}
|
||||
|
||||
const logs = result.executionLogs || [];
|
||||
console.log(`\n 📊 执行日志统计: ${logs.length} 条`);
|
||||
const typeCounts: Record<string, number> = {};
|
||||
logs.forEach((l: any) => { typeCounts[l.type] = (typeCounts[l.type] || 0) + 1; });
|
||||
Object.entries(typeCounts).forEach(([type, count]) => {
|
||||
console.log(` ${type}: ${count}`);
|
||||
});
|
||||
|
||||
console.log(`\n 📝 报告预览 (前 300 字):`);
|
||||
console.log(` ${result.synthesisReport.slice(0, 300).replace(/\n/g, '\n ')}...`);
|
||||
|
||||
// ═══════════ Step 8: Word 导出 ═══════════
|
||||
if (!SKIP_WORD_EXPORT) {
|
||||
printDivider('Step 8: Word 导出(需要 Pandoc 微服务)');
|
||||
|
||||
try {
|
||||
const exportRes = await api(`/asl/research/tasks/${taskId}/export-word`);
|
||||
|
||||
if (exportRes.status === 200 && exportRes.data._binary) {
|
||||
assert(true, `Word 导出成功 (${exportRes.data.byteLength} bytes)`);
|
||||
} else {
|
||||
console.log(` ⚠️ Word 导出返回异常 (HTTP ${exportRes.status})`);
|
||||
console.log(` 这通常是因为 Python 微服务(Pandoc)未运行,可忽略。`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.log(` ⚠️ Word 导出跳过: ${e.message}`);
|
||||
console.log(` 如需测试,请启动 Python 微服务: cd extraction-service && python app.py`);
|
||||
}
|
||||
} else {
|
||||
console.log('\n ⏭️ 跳过 Word 导出测试 (SKIP_WORD_EXPORT=true)');
|
||||
}
|
||||
|
||||
// ═══════════ Step 9: 清理 ═══════════
|
||||
if (!SKIP_CLEANUP && taskId) {
|
||||
printDivider('Step 9: 清理测试数据');
|
||||
try {
|
||||
const { PrismaClient } = await import('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
await prisma.aslResearchTask.delete({ where: { id: taskId } });
|
||||
await prisma.$disconnect();
|
||||
console.log(` 🗑️ 已删除测试任务: ${taskId}`);
|
||||
} catch (e: any) {
|
||||
console.log(` ⚠️ 清理失败: ${e.message} (可手动删除)`);
|
||||
}
|
||||
} else {
|
||||
console.log(`\n ⏭️ 保留测试数据 taskId=${taskId}`);
|
||||
}
|
||||
|
||||
// ═══════════ 总结 ═══════════
|
||||
printDivider('🎉 测试通过!');
|
||||
const totalMs = Date.now() - startTime;
|
||||
console.log(` 总耗时: ${(totalMs / 1000).toFixed(1)}s`);
|
||||
console.log(` 任务 ID: ${taskId}`);
|
||||
console.log(` 综合报告: ${(result.synthesisReport || '').length} 字`);
|
||||
console.log(` 文献数量: ${hasResultList ? result.resultList.length : 0} 篇`);
|
||||
console.log(` 执行日志: ${logs.length} 条`);
|
||||
console.log('');
|
||||
|
||||
} catch (error: any) {
|
||||
printDivider('💥 测试失败');
|
||||
console.error(` 错误: ${error.message}`);
|
||||
console.error(` 任务 ID: ${taskId || '未创建'}`);
|
||||
console.error(` 耗时: ${((Date.now() - startTime) / 1000).toFixed(1)}s`);
|
||||
console.error('');
|
||||
|
||||
if (error.message.includes('登录失败')) {
|
||||
console.error(' 💡 提示: 请检查测试账号配置');
|
||||
console.error(' 可通过环境变量指定: TEST_PHONE=xxx TEST_PASSWORD=yyy npx tsx ...');
|
||||
}
|
||||
|
||||
if (error.message.includes('fetch failed') || error.message.includes('ECONNREFUSED')) {
|
||||
console.error(' 💡 提示: 后端服务未启动,请先运行:');
|
||||
console.error(' cd backend && npm run dev');
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 入口 ───────────────────────────────────────
|
||||
|
||||
runE2E();
|
||||
161
backend/src/modules/asl/__tests__/deep-research-v2-smoke.ts
Normal file
161
backend/src/modules/asl/__tests__/deep-research-v2-smoke.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Deep Research V2.0 — 冒烟测试(Smoke Test)
|
||||
*
|
||||
* 仅测试 API 接口连通性和基本参数校验,
|
||||
* 不调用 LLM / Unifuncs,无外部依赖,几秒完成。
|
||||
*
|
||||
* 运行方式:
|
||||
* npx tsx src/modules/asl/__tests__/deep-research-v2-smoke.ts
|
||||
*
|
||||
* 前置条件:
|
||||
* - 后端服务运行在 localhost:3001
|
||||
* - PostgreSQL 运行中
|
||||
*/
|
||||
|
||||
const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3001';
|
||||
const API_PREFIX = `${BASE_URL}/api/v1`;
|
||||
|
||||
const TEST_PHONE = process.env.TEST_PHONE || '13800000001';
|
||||
const TEST_PASSWORD = process.env.TEST_PASSWORD || '123456';
|
||||
|
||||
let authToken = '';
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
async function api(path: string, options: RequestInit = {}): Promise<{ status: number; data: any }> {
|
||||
const res = await fetch(`${API_PREFIX}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
const data = contentType.includes('json') ? await res.json() : await res.text();
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
function check(ok: boolean, label: string) {
|
||||
if (ok) {
|
||||
console.log(` ✅ ${label}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(` ❌ ${label}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log('🔥 Deep Research V2.0 冒烟测试\n');
|
||||
|
||||
// ─── 1. 登录 ───
|
||||
console.log('[1] 登录');
|
||||
try {
|
||||
const res = await api('/auth/login/password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: TEST_PHONE, password: TEST_PASSWORD }),
|
||||
});
|
||||
const ok = res.status === 200 && res.data.success;
|
||||
check(ok, `POST /auth/login/password → ${res.status}`);
|
||||
if (ok) {
|
||||
authToken = res.data.data.tokens?.accessToken || res.data.data.accessToken || res.data.data.token;
|
||||
} else {
|
||||
console.log(' ⚠️ 登录失败,后续需要认证的测试将跳过');
|
||||
console.log(` 返回: ${JSON.stringify(res.data).slice(0, 200)}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
check(false, `连接后端失败: ${e.message}`);
|
||||
console.log('\n💡 请先启动后端: cd backend && npm run dev\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ─── 2. GET /data-sources ───
|
||||
console.log('\n[2] GET /asl/research/data-sources');
|
||||
{
|
||||
const res = await api('/asl/research/data-sources');
|
||||
check(res.status === 200, `HTTP ${res.status} === 200`);
|
||||
check(res.data.success === true, 'success=true');
|
||||
check(Array.isArray(res.data.data) && res.data.data.length >= 3, `返回 ${res.data.data?.length} 个数据源`);
|
||||
|
||||
const hasPubmed = res.data.data?.some((s: any) => s.id === 'pubmed');
|
||||
check(hasPubmed, 'PubMed 存在于数据源列表');
|
||||
|
||||
const defaultChecked = res.data.data?.filter((s: any) => s.defaultChecked);
|
||||
check(defaultChecked?.length >= 1, `默认选中 ${defaultChecked?.length} 个`);
|
||||
}
|
||||
|
||||
// ─── 3. POST /generate-requirement (参数校验) ───
|
||||
console.log('\n[3] POST /asl/research/generate-requirement — 参数校验');
|
||||
{
|
||||
const res = await api('/asl/research/generate-requirement', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ originalQuery: '' }),
|
||||
});
|
||||
check(res.status === 400, `空 query → HTTP ${res.status} === 400`);
|
||||
}
|
||||
|
||||
{
|
||||
const res = await api('/asl/research/generate-requirement', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
check(res.status === 400, `缺少 query → HTTP ${res.status} === 400`);
|
||||
}
|
||||
|
||||
// ─── 4. PUT /tasks/:taskId/execute (参数校验) ───
|
||||
console.log('\n[4] PUT /asl/research/tasks/:taskId/execute — 参数校验');
|
||||
{
|
||||
const res = await api('/asl/research/tasks/nonexistent-id/execute', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ confirmedRequirement: 'test' }),
|
||||
});
|
||||
check(res.status === 404, `不存在的 taskId → HTTP ${res.status} === 404`);
|
||||
}
|
||||
|
||||
{
|
||||
const res = await api('/asl/research/tasks/some-id/execute', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ confirmedRequirement: '' }),
|
||||
});
|
||||
check(res.status === 400, `空 confirmedRequirement → HTTP ${res.status} === 400`);
|
||||
}
|
||||
|
||||
// ─── 5. GET /tasks/:taskId (不存在) ───
|
||||
console.log('\n[5] GET /asl/research/tasks/:taskId — 不存在');
|
||||
{
|
||||
const res = await api('/asl/research/tasks/nonexistent-id');
|
||||
check(res.status === 404, `不存在 → HTTP ${res.status} === 404`);
|
||||
}
|
||||
|
||||
// ─── 6. GET /tasks/:taskId/export-word (不存在) ───
|
||||
console.log('\n[6] GET /asl/research/tasks/:taskId/export-word — 不存在');
|
||||
{
|
||||
const res = await api('/asl/research/tasks/nonexistent-id/export-word');
|
||||
check(res.status === 500 || res.status === 404, `不存在 → HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
// ─── 7. 未认证访问 ───
|
||||
console.log('\n[7] 未认证访问(无 Token)');
|
||||
{
|
||||
const savedToken = authToken;
|
||||
authToken = '';
|
||||
|
||||
const res = await api('/asl/research/data-sources');
|
||||
check(res.status === 401, `无 Token → HTTP ${res.status} === 401`);
|
||||
|
||||
authToken = savedToken;
|
||||
}
|
||||
|
||||
// ─── 结果汇总 ───
|
||||
console.log(`\n${'═'.repeat(50)}`);
|
||||
console.log(` 🏁 冒烟测试完成: ${passed} 通过, ${failed} 失败 (共 ${passed + failed})`);
|
||||
console.log(`${'═'.repeat(50)}\n`);
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
Reference in New Issue
Block a user