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:
2026-02-23 13:21:52 +08:00
parent b06daecacd
commit 8f06d4f929
39 changed files with 5605 additions and 417 deletions

View 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();