feat(asl): Complete Day 5 - Fulltext Screening Backend API Development

- Implement 5 core API endpoints (create task, get progress, get results, update decision, export Excel)
- Add FulltextScreeningController with Zod validation (652 lines)
- Implement ExcelExporter service with 4-sheet report generation (352 lines)
- Register routes under /api/v1/asl/fulltext-screening
- Create 31 REST Client test cases
- Add automated integration test script
- Fix PDF extraction fallback mechanism in LLM12FieldsService
- Update API design documentation to v3.0
- Update development plan to v1.2
- Create Day 5 development record
- Clean up temporary test files
This commit is contained in:
2025-11-23 10:52:07 +08:00
parent 08aa3f6c28
commit 88cc049fb3
232 changed files with 7780 additions and 441 deletions

View File

@@ -0,0 +1,293 @@
/**
* 全文复筛API集成测试
*
* 运行方式:
* npx tsx src/modules/asl/fulltext-screening/__tests__/api-integration-test.ts
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const BASE_URL = 'http://localhost:3001';
const API_PREFIX = '/api/v1/asl/fulltext-screening';
// 测试辅助函数
async function fetchJSON(url: string, options: RequestInit = {}) {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
const data = await response.json();
return { response, data };
}
// 等待函数
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function runTests() {
console.log('🧪 开始全文复筛API集成测试\n');
try {
// ==================== 准备测试数据 ====================
console.log('📋 步骤1: 准备测试数据...');
// 获取第一个项目
const project = await prisma.aslScreeningProject.findFirst({
include: {
literatures: {
take: 3,
select: {
id: true,
title: true,
},
},
},
});
if (!project) {
throw new Error('未找到测试项目,请先创建项目和文献');
}
if (project.literatures.length === 0) {
throw new Error('项目中没有文献,请先导入文献');
}
const projectId = project.id;
const literatureIds = project.literatures.map((lit) => lit.id).slice(0, 2);
console.log(` ✅ 项目ID: ${projectId}`);
console.log(` ✅ 文献数量: ${literatureIds.length}`);
console.log(` ✅ 文献列表:`, literatureIds);
console.log('');
// ==================== 测试API 1: 创建任务 ====================
console.log('📋 步骤2: 测试创建全文复筛任务...');
const { response: createResponse, data: createData } = await fetchJSON(
`${BASE_URL}${API_PREFIX}/tasks`,
{
method: 'POST',
body: JSON.stringify({
projectId,
literatureIds,
modelA: 'deepseek-v3',
modelB: 'qwen-max',
promptVersion: 'v1.0.0',
}),
}
);
if (createResponse.status !== 201 || !createData.success) {
throw new Error(`创建任务失败: ${JSON.stringify(createData)}`);
}
const taskId = createData.data.taskId;
console.log(` ✅ 任务创建成功`);
console.log(` ✅ 任务ID: ${taskId}`);
console.log(` ✅ 状态: ${createData.data.status}`);
console.log(` ✅ 文献总数: ${createData.data.totalCount}`);
console.log('');
// ==================== 测试API 2: 获取任务进度 ====================
console.log('📋 步骤3: 测试获取任务进度...');
// 等待一段时间让任务开始处理
console.log(' ⏳ 等待3秒让任务开始处理...');
await sleep(3000);
const { response: progressResponse, data: progressData } = await fetchJSON(
`${BASE_URL}${API_PREFIX}/tasks/${taskId}`
);
if (progressResponse.status !== 200 || !progressData.success) {
throw new Error(`获取进度失败: ${JSON.stringify(progressData)}`);
}
console.log(` ✅ 任务状态: ${progressData.data.status}`);
console.log(` ✅ 进度: ${progressData.data.progress.processedCount}/${progressData.data.progress.totalCount} (${progressData.data.progress.progressPercent}%)`);
console.log(` ✅ 成功: ${progressData.data.progress.successCount}`);
console.log(` ✅ 失败: ${progressData.data.progress.failedCount}`);
console.log(` ✅ 降级: ${progressData.data.progress.degradedCount}`);
console.log(` ✅ Token: ${progressData.data.statistics.totalTokens}`);
console.log(` ✅ 成本: ¥${progressData.data.statistics.totalCost.toFixed(4)}`);
console.log('');
// 如果任务还在处理,等待完成
if (progressData.data.status === 'processing' || progressData.data.status === 'pending') {
console.log(' ⏳ 任务仍在处理中,等待完成...');
let attempts = 0;
const maxAttempts = 20; // 最多等待20次约100秒
while (attempts < maxAttempts) {
await sleep(5000); // 每5秒查询一次
const { data: checkData } = await fetchJSON(
`${BASE_URL}${API_PREFIX}/tasks/${taskId}`
);
console.log(` 📊 [${attempts + 1}/${maxAttempts}] 进度: ${checkData.data.progress.progressPercent}%, 状态: ${checkData.data.status}`);
if (checkData.data.status === 'completed' || checkData.data.status === 'failed') {
console.log(` ✅ 任务已完成,状态: ${checkData.data.status}`);
break;
}
attempts++;
}
if (attempts >= maxAttempts) {
console.log(' ⚠️ 任务处理超时但继续测试后续API');
}
console.log('');
}
// ==================== 测试API 3: 获取任务结果 ====================
console.log('📋 步骤4: 测试获取任务结果...');
// 4.1 获取所有结果
const { response: resultsResponse, data: resultsData } = await fetchJSON(
`${BASE_URL}${API_PREFIX}/tasks/${taskId}/results`
);
if (resultsResponse.status !== 200 || !resultsData.success) {
throw new Error(`获取结果失败: ${JSON.stringify(resultsData)}`);
}
console.log(` ✅ 获取所有结果成功`);
console.log(` ✅ 总结果数: ${resultsData.data.total}`);
console.log(` ✅ 当前页结果数: ${resultsData.data.results.length}`);
console.log(` ✅ 冲突数: ${resultsData.data.summary.conflictCount}`);
console.log(` ✅ 待审核: ${resultsData.data.summary.pendingReview}`);
console.log(` ✅ 已审核: ${resultsData.data.summary.reviewed}`);
if (resultsData.data.results.length > 0) {
const firstResult = resultsData.data.results[0];
console.log(`\n 📄 第一个结果详情:`);
console.log(` - 文献ID: ${firstResult.literatureId}`);
console.log(` - 标题: ${firstResult.literature.title.slice(0, 60)}...`);
console.log(` - 模型A状态: ${firstResult.modelAResult.status}`);
console.log(` - 模型B状态: ${firstResult.modelBResult.status}`);
console.log(` - 是否冲突: ${firstResult.conflict.isConflict ? '是' : '否'}`);
console.log(` - 最终决策: ${firstResult.review.finalDecision || '待审核'}`);
}
console.log('');
// 4.2 测试筛选功能
console.log(' 🔍 测试结果筛选功能...');
const { data: conflictData } = await fetchJSON(
`${BASE_URL}${API_PREFIX}/tasks/${taskId}/results?filter=conflict`
);
console.log(` ✅ 冲突项筛选: ${conflictData.data.filtered}`);
const { data: pendingData } = await fetchJSON(
`${BASE_URL}${API_PREFIX}/tasks/${taskId}/results?filter=pending`
);
console.log(` ✅ 待审核筛选: ${pendingData.data.filtered}`);
console.log('');
// ==================== 测试API 4: 人工审核决策 ====================
if (resultsData.data.results.length > 0) {
console.log('📋 步骤5: 测试人工审核决策...');
const resultId = resultsData.data.results[0].resultId;
// 5.1 测试纳入决策
const { response: decisionResponse, data: decisionData } = await fetchJSON(
`${BASE_URL}${API_PREFIX}/results/${resultId}/decision`,
{
method: 'PUT',
body: JSON.stringify({
finalDecision: 'include',
reviewNotes: '集成测试 - 自动审核纳入',
}),
}
);
if (decisionResponse.status !== 200 || !decisionData.success) {
throw new Error(`更新决策失败: ${JSON.stringify(decisionData)}`);
}
console.log(` ✅ 更新决策成功`);
console.log(` ✅ 结果ID: ${decisionData.data.resultId}`);
console.log(` ✅ 最终决策: ${decisionData.data.finalDecision}`);
console.log(` ✅ 审核人: ${decisionData.data.reviewedBy}`);
console.log(` ✅ 审核时间: ${new Date(decisionData.data.reviewedAt).toLocaleString('zh-CN')}`);
console.log('');
// 5.2 测试排除决策(如果有第二个结果)
if (resultsData.data.results.length > 1) {
const secondResultId = resultsData.data.results[1].resultId;
const { data: excludeData } = await fetchJSON(
`${BASE_URL}${API_PREFIX}/results/${secondResultId}/decision`,
{
method: 'PUT',
body: JSON.stringify({
finalDecision: 'exclude',
exclusionReason: '测试排除原因 - 数据不完整',
reviewNotes: '集成测试 - 自动审核排除',
}),
}
);
console.log(` ✅ 排除决策测试成功`);
console.log(` ✅ 排除原因: ${excludeData.data.exclusionReason}`);
console.log('');
}
}
// ==================== 测试API 5: 导出Excel ====================
console.log('📋 步骤6: 测试导出Excel...');
const exportResponse = await fetch(
`${BASE_URL}${API_PREFIX}/tasks/${taskId}/export`,
{
headers: {
Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
},
}
);
if (exportResponse.status !== 200) {
throw new Error(`导出Excel失败: ${exportResponse.statusText}`);
}
const buffer = await exportResponse.arrayBuffer();
console.log(` ✅ Excel导出成功`);
console.log(` ✅ 文件大小: ${(buffer.byteLength / 1024).toFixed(2)} KB`);
console.log(` ✅ Content-Type: ${exportResponse.headers.get('Content-Type')}`);
console.log('');
// ==================== 测试完成 ====================
console.log('✅ 所有测试通过!\n');
console.log('📊 测试总结:');
console.log(' ✅ API 1: 创建任务 - 通过');
console.log(' ✅ API 2: 获取进度 - 通过');
console.log(' ✅ API 3: 获取结果 - 通过');
console.log(' ✅ API 4: 人工审核 - 通过');
console.log(' ✅ API 5: 导出Excel - 通过');
console.log('');
} catch (error: any) {
console.error('\n❌ 测试失败:', error.message);
console.error('\n详细错误:', error);
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
// 运行测试
runTests().catch((error) => {
console.error('测试运行失败:', error);
process.exit(1);
});

View File

@@ -0,0 +1,234 @@
/**
* 端到端真实测试 v2 - 简化版
*
* 使用真实数据测试完整流程:
* 1. 创建项目
* 2. 导入1篇文献简化
* 3. 创建全文复筛任务
* 4. 等待LLM处理
* 5. 查看结果
*/
import axios from 'axios';
import { PrismaClient } from '@prisma/client';
import fs from 'fs/promises';
import path from 'path';
const API_BASE = 'http://localhost:3000/api/v1/asl';
const prisma = new PrismaClient();
interface TestResult {
projectId?: string;
literatureIds?: string[];
taskId?: string;
success: boolean;
error?: string;
}
async function runTest(): Promise<TestResult> {
console.log('🚀 开始端到端真实测试 v2\n');
console.log('⏰ 测试时间:', new Date().toLocaleString('zh-CN'));
console.log('📍 API地址:', API_BASE);
console.log('=' .repeat(80) + '\n');
const result: TestResult = { success: false };
try {
// ========================================
// Step 1: 创建测试项目
// ========================================
console.log('📋 Step 1: 创建测试项目');
const picosPath = path.join(
process.cwd(),
'../docs/03-业务模块/ASL-AI智能文献/05-测试文档/03-测试数据/screening/测试案例的PICOS、纳入标准、排除标准.txt'
);
const picosContent = await fs.readFile(picosPath, 'utf-8');
// 解析PICOS
const populationMatch = picosContent.match(/P \(Population\)[:]\s*(.+)/);
const interventionMatch = picosContent.match(/I \(Intervention\)[:]\s*(.+)/);
const comparisonMatch = picosContent.match(/C \(Comparison\)[:]\s*(.+)/);
const outcomeMatch = picosContent.match(/O \(Outcome\)[:]\s*(.+)/);
const studyDesignMatch = picosContent.match(/S \(Study Design\)[:]\s*(.+)/);
const projectData = {
name: `E2E测试-${Date.now()}`,
description: '端到端真实测试项目',
picoCriteria: {
P: populationMatch?.[1]?.trim() || '缺血性卒中患者',
I: interventionMatch?.[1]?.trim() || '抗血小板治疗',
C: comparisonMatch?.[1]?.trim() || '对照组',
O: outcomeMatch?.[1]?.trim() || '卒中复发',
S: studyDesignMatch?.[1]?.trim() || 'RCT',
},
};
const projectResponse = await axios.post(`${API_BASE}/projects`, projectData);
result.projectId = projectResponse.data.data.id;
console.log(`✅ 项目创建成功: ${result.projectId}\n`);
// ========================================
// Step 2: 导入1篇简单测试文献
// ========================================
console.log('📚 Step 2: 导入测试文献(使用简化数据)');
const literatureData = {
projectId: result.projectId,
literatures: [
{
pmid: 'TEST001',
title: 'Antiplatelet Therapy for Secondary Stroke Prevention: A Randomized Controlled Trial',
abstract: 'Background: Stroke is a major cause of death worldwide. This study evaluates antiplatelet therapy effectiveness. Methods: We conducted an RCT with 500 patients randomized to aspirin vs clopidogrel groups. The study was double-blind. Results: Primary outcome (stroke recurrence) occurred in 12% of aspirin group vs 8% of clopidogrel group (p=0.03). Secondary outcomes showed similar trends. Conclusion: Clopidogrel demonstrates superior efficacy for secondary stroke prevention in Asian patients.',
authors: 'Zhang W, Li H, Wang Y',
journal: 'Stroke Research',
publicationYear: 2023,
hasPdf: false,
},
],
};
const importResponse = await axios.post(`${API_BASE}/literatures/import`, literatureData);
console.log(`✅ 文献导入成功: ${importResponse.data.data.importedCount}\n`);
// 获取文献ID
const literatures = await prisma.aslLiterature.findMany({
where: { projectId: result.projectId },
select: { id: true, title: true },
});
result.literatureIds = literatures.map(lit => lit.id);
console.log('📄 导入的文献:');
literatures.forEach(lit => {
console.log(` - ${lit.id.slice(0, 8)}: ${lit.title.slice(0, 60)}...`);
});
console.log('');
// ========================================
// Step 3: 创建全文复筛任务
// ========================================
console.log('🤖 Step 3: 创建全文复筛任务');
const taskData = {
projectId: result.projectId,
literatureIds: result.literatureIds,
config: {
modelA: 'deepseek-v3',
modelB: 'qwen-max',
concurrency: 1,
skipExtraction: true, // 跳过PDF提取使用标题+摘要
},
};
const taskResponse = await axios.post(`${API_BASE}/fulltext-screening/tasks`, taskData);
result.taskId = taskResponse.data.data.taskId;
console.log(`✅ 任务创建成功: ${result.taskId}\n`);
// ========================================
// Step 4: 监控任务进度
// ========================================
console.log('⏳ Step 4: 监控任务进度等待LLM处理\n');
let maxAttempts = 30; // 最多等待5分钟
let attempt = 0;
let taskCompleted = false;
while (attempt < maxAttempts && !taskCompleted) {
await new Promise(resolve => setTimeout(resolve, 10000)); // 每10秒查询一次
attempt++;
try {
const progressResponse = await axios.get(
`${API_BASE}/fulltext-screening/tasks/${result.taskId}/progress`
);
const progress = progressResponse.data.data;
console.log(`[${attempt}/${maxAttempts}] 进度: ${progress.processedCount}/${progress.totalCount} | ` +
`成功: ${progress.successCount} | 失败: ${progress.failedCount} | ` +
`Token: ${progress.totalTokens} | 成本: ¥${progress.totalCost.toFixed(4)}`);
if (progress.status === 'completed' || progress.status === 'failed') {
taskCompleted = true;
console.log(`\n✅ 任务完成!状态: ${progress.status}\n`);
}
} catch (error: any) {
console.log(`⚠️ 查询进度失败: ${error.message}`);
}
}
if (!taskCompleted) {
console.log('⚠️ 任务超时,但可能仍在后台处理\n');
}
// ========================================
// Step 5: 获取结果
// ========================================
console.log('📊 Step 5: 获取处理结果\n');
try {
const resultsResponse = await axios.get(
`${API_BASE}/fulltext-screening/tasks/${result.taskId}/results`
);
const results = resultsResponse.data.data;
console.log('=' .repeat(80));
console.log('📈 最终统计:');
console.log(` - 总文献数: ${results.results.length}`);
console.log(` - 总Token: ${results.summary.totalTokens}`);
console.log(` - 总成本: ¥${results.summary.totalCost.toFixed(4)}`);
console.log('');
if (results.results.length > 0) {
console.log('📄 文献结果详情:');
results.results.forEach((r: any, idx: number) => {
console.log(`\n[${idx + 1}] ${r.literatureTitle}`);
console.log(` Model A (${r.modelAName}): ${r.modelAStatus}`);
console.log(` Model B (${r.modelBName}): ${r.modelBStatus}`);
console.log(` Token: ${r.modelATokens + r.modelBTokens}`);
console.log(` 成本: ¥${(r.modelACost + r.modelBCost).toFixed(4)}`);
if (r.modelAStatus === 'success' && r.modelAOverall) {
console.log(` 决策: ${r.modelAOverall.overall_decision || 'N/A'}`);
}
});
}
result.success = results.results.length > 0;
} catch (error: any) {
console.log(`❌ 获取结果失败: ${error.message}`);
}
console.log('\n' + '=' .repeat(80));
console.log('🎉 测试完成!\n');
} catch (error: any) {
console.error('\n❌ 测试失败:', error.message);
if (error.response?.data) {
console.error('错误详情:', JSON.stringify(error.response.data, null, 2));
}
result.success = false;
result.error = error.message;
} finally {
await prisma.$disconnect();
}
return result;
}
// 运行测试
runTest()
.then(result => {
if (result.success) {
console.log('✅ 端到端测试成功!');
process.exit(0);
} else {
console.log('❌ 端到端测试失败');
process.exit(1);
}
})
.catch(error => {
console.error('💥 测试执行异常:', error);
process.exit(1);
});

View File

@@ -0,0 +1,402 @@
/**
* 全文复筛端到端集成测试真实LLM调用
*
* 测试流程:
* 1. 创建真实项目使用测试案例的PICOS
* 2. 导入2篇真实文献
* 3. 调用全文复筛API
* 4. 使用真实LLMDeepSeek-V3 + Qwen-Max处理
* 5. 验证12字段提取结果
* 6. 检查冲突检测
* 7. 导出Excel并保存
* 8. 输出详细测试报告
*
* 运行方式:
* npx tsx src/modules/asl/fulltext-screening/__tests__/e2e-real-test.ts
*
* 预计成本¥0.1-0.2
*/
import { PrismaClient } from '@prisma/client';
import fs from 'fs';
import path from 'path';
const prisma = new PrismaClient();
const BASE_URL = 'http://localhost:3001';
const API_PREFIX = '/api/v1/asl';
// 测试用PICOS来自真实案例
const TEST_PICOS = {
population: '非心源性缺血性卒中NCIS、亚洲人群',
intervention: '抗血小板治疗药物(阿司匹林、氯吡格雷、替格瑞洛等)或抗凝药物(华法林、低分子肝素等)',
comparison: '对照组(安慰剂或标准治疗)',
outcome: '疗效安全性卒中进展、卒中复发、死亡、NIHSS评分变化、VTE、疗效、安全性',
studyDesign: '系统评价SR、随机对照试验RCT、真实世界研究RWE、观察性研究OBS'
};
const INCLUSION_CRITERIA = `
1. 非心源性缺血性卒中、亚洲患者
2. 接受二级预防治疗(抗血小板或抗凝治疗)
3. 涉及相关药物:阿司匹林、氯吡格雷、替格瑞洛、华法林等
4. 研究类型SR、RCT、RWE、OBS
5. 研究时间2020年之后
`;
const EXCLUSION_CRITERIA = `
1. 心源性卒中患者、非亚洲人群
2. 仅涉及急性期治疗(溶栓、取栓)而非二级预防
3. 房颤患者
4. 急性冠脉综合征ACS患者无卒中史
5. 病例报告
6. 非中英文文献
`;
// 测试文献元数据
const TEST_LITERATURES = [
{
pmid: '256859669',
title: 'Effect of antiplatelet therapy on stroke recurrence in Asian patients with non-cardioembolic ischemic stroke',
abstract: 'Background: Secondary prevention of stroke is crucial. This study investigates antiplatelet therapy effectiveness. Methods: RCT comparing aspirin vs clopidogrel. Results: Reduced recurrence observed.',
authors: 'Zhang Y, Li X, Wang H',
journal: 'Stroke Research',
publicationYear: 2022,
doi: '10.1234/stroke.2022.001',
},
{
pmid: '256859738',
title: 'Dual antiplatelet therapy for secondary stroke prevention in elderly Asian patients',
abstract: 'Objective: Evaluate dual antiplatelet therapy (DAPT) in elderly. Design: Observational study. Findings: DAPT shows efficacy with acceptable safety profile.',
authors: 'Chen W, Kim S, Liu J',
journal: 'Journal of Neurology',
publicationYear: 2023,
doi: '10.1234/jneuro.2023.002',
},
];
// 辅助函数
async function fetchJSON(url: string, options: RequestInit = {}) {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
const data = await response.json();
return { response, data };
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function runE2ETest() {
console.log('🧪 开始全文复筛端到端集成测试真实LLM\n');
console.log('⚠️ 注意此测试将调用真实LLM API产生约¥0.1-0.2成本\n');
let projectId: string;
let literatureIds: string[] = [];
let taskId: string;
try {
// ==================== 步骤1: 创建测试项目 ====================
console.log('📋 步骤1: 创建测试项目...');
const { response: createProjectRes, data: projectData } = await fetchJSON(
`${BASE_URL}${API_PREFIX}/projects`,
{
method: 'POST',
body: JSON.stringify({
projectName: `[E2E测试] 非心源性卒中二级预防 - ${new Date().toLocaleString('zh-CN')}`,
picoCriteria: TEST_PICOS,
inclusionCriteria: INCLUSION_CRITERIA,
exclusionCriteria: EXCLUSION_CRITERIA,
screeningConfig: {
models: ['deepseek-v3', 'qwen-max'],
temperature: 0,
},
}),
}
);
if (createProjectRes.status !== 201 || !projectData.success) {
throw new Error(`创建项目失败: ${JSON.stringify(projectData)}`);
}
projectId = projectData.data.id;
console.log(` ✅ 项目创建成功: ${projectId}`);
console.log(` ✅ 项目名称: ${projectData.data.projectName}`);
console.log('');
// ==================== 步骤2: 导入文献 ====================
console.log('📋 步骤2: 导入测试文献...');
const { response: importRes, data: importData } = await fetchJSON(
`${BASE_URL}${API_PREFIX}/literatures/import`,
{
method: 'POST',
body: JSON.stringify({
projectId,
literatures: TEST_LITERATURES,
}),
}
);
if (importRes.status !== 201 || !importData.success) {
throw new Error(`导入文献失败: ${JSON.stringify(importData)}`);
}
console.log(` ✅ 文献导入成功: ${importData.data.importedCount}`);
// 直接从数据库获取导入的文献
const importedLiteratures = await prisma.aslLiterature.findMany({
where: { projectId },
orderBy: { createdAt: 'desc' },
take: 2,
select: { id: true, title: true },
});
if (importedLiteratures.length === 0) {
throw new Error('未找到导入的文献');
}
literatureIds = importedLiteratures.map((lit) => lit.id);
console.log(` ✅ 获取文献ID: ${literatureIds.length}`);
TEST_LITERATURES.forEach((lit, idx) => {
console.log(` ${idx + 1}. ${lit.title.slice(0, 80)}...`);
});
console.log('');
// ==================== 步骤3: 创建全文复筛任务 ====================
console.log('📋 步骤3: 创建全文复筛任务...');
console.log(' 🤖 模型配置: DeepSeek-V3 + Qwen-Max');
console.log(' ⚠️ 开始调用真实LLM API...\n');
const { response: createTaskRes, data: taskData } = await fetchJSON(
`${BASE_URL}${API_PREFIX}/fulltext-screening/tasks`,
{
method: 'POST',
body: JSON.stringify({
projectId,
literatureIds,
modelA: 'deepseek-v3',
modelB: 'qwen-max',
promptVersion: 'v1.0.0',
}),
}
);
if (createTaskRes.status !== 201 || !taskData.success) {
throw new Error(`创建任务失败: ${JSON.stringify(taskData)}`);
}
taskId = taskData.data.taskId;
console.log(` ✅ 任务创建成功: ${taskId}`);
console.log(` ✅ 状态: ${taskData.data.status}`);
console.log(` ⏳ 预计处理时间: 2-5分钟\n`);
// ==================== 步骤4: 监控任务进度 ====================
console.log('📋 步骤4: 监控任务处理进度...\n');
let attempts = 0;
const maxAttempts = 60; // 最多等待5分钟每5秒查询一次
let completed = false;
while (attempts < maxAttempts && !completed) {
await sleep(5000); // 每5秒查询一次
const { data: progressData } = await fetchJSON(
`${BASE_URL}${API_PREFIX}/fulltext-screening/tasks/${taskId}`
);
const progress = progressData.data.progress;
const stats = progressData.data.statistics;
console.log(` [${attempts + 1}/${maxAttempts}] 进度: ${progress.progressPercent}%`);
console.log(` - 已处理: ${progress.processedCount}/${progress.totalCount}`);
console.log(` - 成功: ${progress.successCount}, 失败: ${progress.failedCount}, 降级: ${progress.degradedCount}`);
console.log(` - Token: ${stats.totalTokens.toLocaleString()}, 成本: ¥${stats.totalCost.toFixed(4)}`);
if (progressData.data.status === 'completed') {
console.log('\n ✅ 任务处理完成!\n');
completed = true;
break;
} else if (progressData.data.status === 'failed') {
throw new Error(`任务失败: ${progressData.data.error?.message || '未知错误'}`);
}
attempts++;
}
if (!completed) {
console.log('\n ⚠️ 任务处理超时,但继续获取当前结果...\n');
}
// ==================== 步骤5: 获取并分析结果 ====================
console.log('📋 步骤5: 获取全文复筛结果...\n');
const { data: resultsData } = await fetchJSON(
`${BASE_URL}${API_PREFIX}/fulltext-screening/tasks/${taskId}/results`
);
if (!resultsData.success) {
throw new Error(`获取结果失败: ${JSON.stringify(resultsData)}`);
}
const results = resultsData.data.results;
const summary = resultsData.data.summary;
console.log(' 📊 结果总览:');
console.log(` - 总结果数: ${summary.totalResults}`);
console.log(` - 冲突数: ${summary.conflictCount}`);
console.log(` - 待审核: ${summary.pendingReview}`);
console.log(` - 已审核: ${summary.reviewed}\n`);
// 分析每个结果
if (results.length > 0) {
console.log(' 📄 详细结果分析:\n');
results.forEach((result: any, idx: number) => {
console.log(` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
console.log(` 文献 ${idx + 1}: ${result.literature.title.slice(0, 70)}...`);
console.log(` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
// 模型A结果
console.log(` 🤖 模型A (${result.modelAResult.modelName}):`);
console.log(` 状态: ${result.modelAResult.status}`);
console.log(` Token: ${result.modelAResult.tokens}, 成本: ¥${result.modelAResult.cost?.toFixed(4)}`);
if (result.modelAResult.status === 'success') {
const overall = result.modelAResult.overall;
console.log(` 决策: ${overall?.decision || 'N/A'}`);
console.log(` 置信度: ${overall?.confidence || 'N/A'}`);
console.log(` 数据质量: ${overall?.dataQuality || 'N/A'}`);
console.log(` 理由: ${overall?.reason?.slice(0, 100) || 'N/A'}...`);
// 显示12字段概览
const fields = result.modelAResult.fields;
if (fields) {
const fieldKeys = Object.keys(fields);
console.log(` 字段提取: ${fieldKeys.length}/12个字段`);
// 显示几个关键字段
['field5_population', 'field7_intervention', 'field9_outcomes'].forEach(key => {
if (fields[key]) {
console.log(` - ${key}: ${fields[key].assessment || 'N/A'}`);
}
});
}
} else {
console.log(` 错误: ${result.modelAResult.error || 'N/A'}`);
}
console.log('');
// 模型B结果
console.log(` 🤖 模型B (${result.modelBResult.modelName}):`);
console.log(` 状态: ${result.modelBResult.status}`);
console.log(` Token: ${result.modelBResult.tokens}, 成本: ¥${result.modelBResult.cost?.toFixed(4)}`);
if (result.modelBResult.status === 'success') {
const overall = result.modelBResult.overall;
console.log(` 决策: ${overall?.decision || 'N/A'}`);
console.log(` 置信度: ${overall?.confidence || 'N/A'}`);
} else {
console.log(` 错误: ${result.modelBResult.error || 'N/A'}`);
}
console.log('');
// 冲突检测
if (result.conflict.isConflict) {
console.log(` ⚠️ 冲突检测:`);
console.log(` 严重程度: ${result.conflict.severity}`);
console.log(` 冲突字段: ${result.conflict.conflictFields.join(', ')}`);
console.log(` 审核优先级: ${result.review.priority}`);
} else {
console.log(` ✅ 无冲突 (双模型一致)`);
}
console.log('');
});
} else {
console.log(' ⚠️ 暂无结果(可能任务还在处理中)\n');
}
// ==================== 步骤6: 导出Excel ====================
console.log('📋 步骤6: 导出Excel报告...\n');
const exportResponse = await fetch(
`${BASE_URL}${API_PREFIX}/fulltext-screening/tasks/${taskId}/export`
);
if (exportResponse.status !== 200) {
throw new Error(`导出Excel失败: ${exportResponse.statusText}`);
}
const buffer = await exportResponse.arrayBuffer();
const outputDir = path.join(process.cwd(), 'test-output');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `fulltext_screening_e2e_test_${timestamp}.xlsx`;
const filepath = path.join(outputDir, filename);
fs.writeFileSync(filepath, Buffer.from(buffer));
console.log(` ✅ Excel导出成功`);
console.log(` ✅ 文件路径: ${filepath}`);
console.log(` ✅ 文件大小: ${(buffer.byteLength / 1024).toFixed(2)} KB\n`);
// ==================== 测试总结 ====================
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('✅ 端到端集成测试完成!\n');
console.log('📊 测试总结:');
console.log(` ✅ 项目创建: 成功`);
console.log(` ✅ 文献导入: ${literatureIds.length}`);
console.log(` ✅ 任务创建: 成功`);
console.log(` ✅ LLM处理: ${summary?.totalResults || 0}篇完成`);
console.log(` ✅ Excel导出: 成功`);
console.log('');
console.log('💰 成本统计:');
const finalProgress = await fetchJSON(
`${BASE_URL}${API_PREFIX}/fulltext-screening/tasks/${taskId}`
);
const finalStats = finalProgress.data.data.statistics;
console.log(` - 总Token: ${finalStats.totalTokens.toLocaleString()}`);
console.log(` - 总成本: ¥${finalStats.totalCost.toFixed(4)}`);
console.log(` - 平均成本/篇: ¥${(finalStats.totalCost / literatureIds.length).toFixed(4)}`);
console.log('');
console.log('📁 输出文件:');
console.log(` - Excel报告: ${filepath}`);
console.log('');
console.log('🔗 相关链接:');
console.log(` - 项目ID: ${projectId}`);
console.log(` - 任务ID: ${taskId}`);
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
} catch (error: any) {
console.error('\n❌ 测试失败:', error.message);
console.error('\n详细错误:', error);
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
// 运行测试
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('🧪 全文复筛端到端集成测试');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
runE2ETest().catch((error) => {
console.error('测试运行失败:', error);
process.exit(1);
});

View File

@@ -0,0 +1,272 @@
###
# 全文复筛API测试
# 使用REST Client插件运行VS Code
###
@baseUrl = http://localhost:3001
@apiPrefix = /api/v1/asl/fulltext-screening
### ========================================
### 准备工作:获取已有项目和文献
### ========================================
### 1. 获取项目列表
GET {{baseUrl}}/api/v1/asl/projects
Content-Type: application/json
### 2. 获取项目文献列表替换projectId
@projectId = 55941145-bba0-4b15-bda4-f0a398d78208
GET {{baseUrl}}/api/v1/asl/projects/{{projectId}}/literatures?page=1&limit=10
Content-Type: application/json
### ========================================
### API 1: 创建全文复筛任务
### ========================================
### 测试1.1: 创建任务(正常情况)
# @name createTask
POST {{baseUrl}}{{apiPrefix}}/tasks
Content-Type: application/json
{
"projectId": "{{projectId}}",
"literatureIds": [
"e9c18ba3-9ad7-4cc9-ac78-b74a7ec91b12",
"e44ea8d9-6ba8-4b88-8d24-4fb46b3584e0"
],
"modelA": "deepseek-v3",
"modelB": "qwen-max",
"promptVersion": "v1.0.0"
}
### 保存taskId
@taskId = {{createTask.response.body.data.taskId}}
### 测试1.2: 创建任务(缺少必填参数)
POST {{baseUrl}}{{apiPrefix}}/tasks
Content-Type: application/json
{
"projectId": "{{projectId}}"
}
### 测试1.3: 创建任务projectId不存在
POST {{baseUrl}}{{apiPrefix}}/tasks
Content-Type: application/json
{
"projectId": "00000000-0000-0000-0000-000000000000",
"literatureIds": ["lit-001"]
}
### 测试1.4: 创建任务literatureIds为空
POST {{baseUrl}}{{apiPrefix}}/tasks
Content-Type: application/json
{
"projectId": "{{projectId}}",
"literatureIds": []
}
### ========================================
### API 2: 获取任务进度
### ========================================
### 测试2.1: 获取任务进度(正常情况)
GET {{baseUrl}}{{apiPrefix}}/tasks/{{taskId}}
Content-Type: application/json
### 测试2.2: 获取任务进度taskId不存在
GET {{baseUrl}}{{apiPrefix}}/tasks/00000000-0000-0000-0000-000000000000
Content-Type: application/json
### 测试2.3: 等待5秒后再次查询进度
GET {{baseUrl}}{{apiPrefix}}/tasks/{{taskId}}
Content-Type: application/json
### ========================================
### API 3: 获取任务结果
### ========================================
### 测试3.1: 获取所有结果(默认参数)
GET {{baseUrl}}{{apiPrefix}}/tasks/{{taskId}}/results
Content-Type: application/json
### 测试3.2: 获取所有结果第一页每页10条
GET {{baseUrl}}{{apiPrefix}}/tasks/{{taskId}}/results?page=1&pageSize=10
Content-Type: application/json
### 测试3.3: 仅获取冲突项
GET {{baseUrl}}{{apiPrefix}}/tasks/{{taskId}}/results?filter=conflict
Content-Type: application/json
### 测试3.4: 仅获取待审核项
GET {{baseUrl}}{{apiPrefix}}/tasks/{{taskId}}/results?filter=pending
Content-Type: application/json
### 测试3.5: 仅获取已审核项
GET {{baseUrl}}{{apiPrefix}}/tasks/{{taskId}}/results?filter=reviewed
Content-Type: application/json
### 测试3.6: 按优先级降序排序
GET {{baseUrl}}{{apiPrefix}}/tasks/{{taskId}}/results?sortBy=priority&sortOrder=desc
Content-Type: application/json
### 测试3.7: 按创建时间升序排序
GET {{baseUrl}}{{apiPrefix}}/tasks/{{taskId}}/results?sortBy=createdAt&sortOrder=asc
Content-Type: application/json
### 测试3.8: 分页测试第2页
GET {{baseUrl}}{{apiPrefix}}/tasks/{{taskId}}/results?page=2&pageSize=5
Content-Type: application/json
### 测试3.9: 无效的filter参数
GET {{baseUrl}}{{apiPrefix}}/tasks/{{taskId}}/results?filter=invalid
Content-Type: application/json
### 测试3.10: 无效的pageSize超过最大值
GET {{baseUrl}}{{apiPrefix}}/tasks/{{taskId}}/results?pageSize=999
Content-Type: application/json
### ========================================
### API 4: 人工审核决策
### ========================================
### 先获取一个结果ID
# @name getFirstResult
GET {{baseUrl}}{{apiPrefix}}/tasks/{{taskId}}/results?pageSize=1
Content-Type: application/json
### 保存resultId
@resultId = {{getFirstResult.response.body.data.results[0].resultId}}
### 测试4.1: 更新决策为纳入
PUT {{baseUrl}}{{apiPrefix}}/results/{{resultId}}/decision
Content-Type: application/json
{
"finalDecision": "include",
"reviewNotes": "经人工审核,确认纳入"
}
### 测试4.2: 更新决策为排除(带排除原因)
PUT {{baseUrl}}{{apiPrefix}}/results/{{resultId}}/decision
Content-Type: application/json
{
"finalDecision": "exclude",
"exclusionReason": "关键字段field9结局指标数据不完整",
"reviewNotes": "仅报告P值缺少均值±SD"
}
### 测试4.3: 排除但不提供排除原因(应该失败)
PUT {{baseUrl}}{{apiPrefix}}/results/{{resultId}}/decision
Content-Type: application/json
{
"finalDecision": "exclude"
}
### 测试4.4: 无效的finalDecision值
PUT {{baseUrl}}{{apiPrefix}}/results/{{resultId}}/decision
Content-Type: application/json
{
"finalDecision": "maybe"
}
### 测试4.5: resultId不存在
PUT {{baseUrl}}{{apiPrefix}}/results/00000000-0000-0000-0000-000000000000/decision
Content-Type: application/json
{
"finalDecision": "include"
}
### ========================================
### API 5: 导出Excel
### ========================================
### 测试5.1: 导出Excel正常情况
GET {{baseUrl}}{{apiPrefix}}/tasks/{{taskId}}/export
Accept: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
### 测试5.2: 导出ExceltaskId不存在
GET {{baseUrl}}{{apiPrefix}}/tasks/00000000-0000-0000-0000-000000000000/export
Accept: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
### ========================================
### 完整流程测试
### ========================================
### 完整流程1: 创建任务
# @name fullFlowTask
POST {{baseUrl}}{{apiPrefix}}/tasks
Content-Type: application/json
{
"projectId": "{{projectId}}",
"literatureIds": [
"e9c18ba3-9ad7-4cc9-ac78-b74a7ec91b12"
],
"modelA": "deepseek-v3",
"modelB": "qwen-max"
}
@fullFlowTaskId = {{fullFlowTask.response.body.data.taskId}}
### 完整流程2: 等待2秒后查询进度
GET {{baseUrl}}{{apiPrefix}}/tasks/{{fullFlowTaskId}}
Content-Type: application/json
### 完整流程3: 获取结果
# @name fullFlowResults
GET {{baseUrl}}{{apiPrefix}}/tasks/{{fullFlowTaskId}}/results
Content-Type: application/json
@fullFlowResultId = {{fullFlowResults.response.body.data.results[0].resultId}}
### 完整流程4: 审核决策
PUT {{baseUrl}}{{apiPrefix}}/results/{{fullFlowResultId}}/decision
Content-Type: application/json
{
"finalDecision": "include",
"reviewNotes": "完整流程测试 - 确认纳入"
}
### 完整流程5: 导出Excel
GET {{baseUrl}}{{apiPrefix}}/tasks/{{fullFlowTaskId}}/export
Accept: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
### ========================================
### 压力测试(批量文献)
### ========================================
### 批量测试: 创建包含多篇文献的任务
POST {{baseUrl}}{{apiPrefix}}/tasks
Content-Type: application/json
{
"projectId": "{{projectId}}",
"literatureIds": [
"e9c18ba3-9ad7-4cc9-ac78-b74a7ec91b12",
"e44ea8d9-6ba8-4b88-8d24-4fb46b3584e0",
"c8f9e2d1-3a4b-5c6d-7e8f-9a0b1c2d3e4f"
],
"modelA": "deepseek-v3",
"modelB": "qwen-max"
}
### ========================================
### 清理测试数据(可选)
### ========================================
### 注意:以下操作会删除测试数据,请谨慎使用
### 查询所有任务
GET {{baseUrl}}/api/v1/asl/projects/{{projectId}}/literatures
Content-Type: application/json
###