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:
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Excel导出服务
|
||||
*
|
||||
* 生成全文复筛结果的Excel文件,包含:
|
||||
* - Sheet 1: 纳入文献列表
|
||||
* - Sheet 2: 排除文献列表
|
||||
* - Sheet 3: PRISMA统计
|
||||
* - Sheet 4: 成本统计
|
||||
*/
|
||||
|
||||
import ExcelJS from 'exceljs';
|
||||
import { logger } from '../../../../common/logging/index.js';
|
||||
|
||||
export class ExcelExporter {
|
||||
/**
|
||||
* 生成全文复筛Excel
|
||||
*/
|
||||
async generateFulltextScreeningExcel(
|
||||
task: any,
|
||||
results: any[]
|
||||
): Promise<Buffer> {
|
||||
logger.info('Generating fulltext screening Excel', {
|
||||
taskId: task.id,
|
||||
resultsCount: results.length,
|
||||
});
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = 'AI智能文献系统';
|
||||
workbook.created = new Date();
|
||||
|
||||
// Sheet 1: 纳入文献列表
|
||||
await this.createIncludedSheet(workbook, results);
|
||||
|
||||
// Sheet 2: 排除文献列表
|
||||
await this.createExcludedSheet(workbook, results);
|
||||
|
||||
// Sheet 3: PRISMA统计
|
||||
await this.createStatisticsSheet(workbook, task, results);
|
||||
|
||||
// Sheet 4: 成本统计
|
||||
await this.createCostSheet(workbook, task, results);
|
||||
|
||||
// 生成Buffer
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
logger.info('Excel generated successfully', {
|
||||
sheetCount: workbook.worksheets.length,
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
|
||||
return buffer as Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sheet 1: 纳入文献列表
|
||||
*/
|
||||
private async createIncludedSheet(workbook: ExcelJS.Workbook, results: any[]) {
|
||||
const sheet = workbook.addWorksheet('纳入文献列表');
|
||||
|
||||
// 设置列
|
||||
sheet.columns = [
|
||||
{ header: '序号', key: 'index', width: 8 },
|
||||
{ header: 'PMID', key: 'pmid', width: 12 },
|
||||
{ header: '文献来源', key: 'source', width: 30 },
|
||||
{ header: '标题', key: 'title', width: 60 },
|
||||
{ header: '期刊', key: 'journal', width: 30 },
|
||||
{ header: '年份', key: 'year', width: 10 },
|
||||
{ header: 'DOI', key: 'doi', width: 25 },
|
||||
{ header: '最终决策', key: 'decision', width: 12 },
|
||||
{ header: '数据质量', key: 'dataQuality', width: 12 },
|
||||
{ header: '模型一致性', key: 'consistency', width: 12 },
|
||||
{ header: '是否人工审核', key: 'isReviewed', width: 14 },
|
||||
];
|
||||
|
||||
// 样式:表头
|
||||
sheet.getRow(1).font = { bold: true };
|
||||
sheet.getRow(1).fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FF4472C4' },
|
||||
};
|
||||
sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
sheet.getRow(1).alignment = { vertical: 'middle', horizontal: 'center' };
|
||||
|
||||
// 筛选纳入的文献
|
||||
const includedResults = results.filter(
|
||||
(r) => r.finalDecision === 'include'
|
||||
);
|
||||
|
||||
// 填充数据
|
||||
includedResults.forEach((result, index) => {
|
||||
const lit = result.literature;
|
||||
const modelAOverall = result.modelAOverall as any;
|
||||
const modelBOverall = result.modelBOverall as any;
|
||||
|
||||
const consistency =
|
||||
modelAOverall?.decision === modelBOverall?.decision
|
||||
? '一致'
|
||||
: '不一致';
|
||||
|
||||
const dataQuality = modelAOverall?.dataQuality || modelBOverall?.dataQuality || '-';
|
||||
|
||||
sheet.addRow({
|
||||
index: index + 1,
|
||||
pmid: lit.pmid || '-',
|
||||
source: `${lit.authors?.split(',')[0] || 'Unknown'} ${lit.year || '-'}`,
|
||||
title: lit.title || '-',
|
||||
journal: lit.journal || '-',
|
||||
year: lit.year || '-',
|
||||
doi: lit.doi || '-',
|
||||
decision: '纳入',
|
||||
dataQuality,
|
||||
consistency,
|
||||
isReviewed: result.finalDecisionBy ? '是' : '否',
|
||||
});
|
||||
});
|
||||
|
||||
// 冻结首行
|
||||
sheet.views = [{ state: 'frozen', ySplit: 1 }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sheet 2: 排除文献列表
|
||||
*/
|
||||
private async createExcludedSheet(workbook: ExcelJS.Workbook, results: any[]) {
|
||||
const sheet = workbook.addWorksheet('排除文献列表');
|
||||
|
||||
// 设置列
|
||||
sheet.columns = [
|
||||
{ header: '序号', key: 'index', width: 8 },
|
||||
{ header: 'PMID', key: 'pmid', width: 12 },
|
||||
{ header: '文献来源', key: 'source', width: 30 },
|
||||
{ header: '标题', key: 'title', width: 60 },
|
||||
{ header: '排除原因', key: 'reason', width: 50 },
|
||||
{ header: '排除字段', key: 'fields', width: 20 },
|
||||
{ header: '是否冲突', key: 'isConflict', width: 12 },
|
||||
{ header: '审核人', key: 'reviewer', width: 20 },
|
||||
{ header: '审核时间', key: 'reviewTime', width: 20 },
|
||||
];
|
||||
|
||||
// 样式:表头
|
||||
sheet.getRow(1).font = { bold: true };
|
||||
sheet.getRow(1).fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFE74C3C' },
|
||||
};
|
||||
sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
sheet.getRow(1).alignment = { vertical: 'middle', horizontal: 'center' };
|
||||
|
||||
// 筛选排除的文献
|
||||
const excludedResults = results.filter(
|
||||
(r) => r.finalDecision === 'exclude'
|
||||
);
|
||||
|
||||
// 填充数据
|
||||
excludedResults.forEach((result, index) => {
|
||||
const lit = result.literature;
|
||||
|
||||
sheet.addRow({
|
||||
index: index + 1,
|
||||
pmid: lit.pmid || '-',
|
||||
source: `${lit.authors?.split(',')[0] || 'Unknown'} ${lit.year || '-'}`,
|
||||
title: lit.title || '-',
|
||||
reason: result.exclusionReason || '-',
|
||||
fields: result.conflictFields?.join(', ') || '-',
|
||||
isConflict: result.isConflict ? '是' : '否',
|
||||
reviewer: result.finalDecisionBy || '-',
|
||||
reviewTime: result.finalDecisionAt
|
||||
? new Date(result.finalDecisionAt).toLocaleString('zh-CN')
|
||||
: '-',
|
||||
});
|
||||
});
|
||||
|
||||
// 冻结首行
|
||||
sheet.views = [{ state: 'frozen', ySplit: 1 }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sheet 3: PRISMA统计
|
||||
*/
|
||||
private async createStatisticsSheet(
|
||||
workbook: ExcelJS.Workbook,
|
||||
task: any,
|
||||
results: any[]
|
||||
) {
|
||||
const sheet = workbook.addWorksheet('PRISMA统计');
|
||||
|
||||
// 统计数据
|
||||
const total = results.length;
|
||||
const included = results.filter((r) => r.finalDecision === 'include').length;
|
||||
const excluded = results.filter((r) => r.finalDecision === 'exclude').length;
|
||||
const pending = total - included - excluded;
|
||||
const conflictCount = results.filter((r) => r.isConflict).length;
|
||||
const reviewedCount = results.filter((r) => r.finalDecisionBy).length;
|
||||
|
||||
// 排除原因统计
|
||||
const exclusionReasons: Record<string, number> = {};
|
||||
results
|
||||
.filter((r) => r.finalDecision === 'exclude' && r.exclusionReason)
|
||||
.forEach((r) => {
|
||||
const reason = r.exclusionReason as string;
|
||||
exclusionReasons[reason] = (exclusionReasons[reason] || 0) + 1;
|
||||
});
|
||||
|
||||
// 设置列宽
|
||||
sheet.getColumn(1).width = 30;
|
||||
sheet.getColumn(2).width = 15;
|
||||
sheet.getColumn(3).width = 15;
|
||||
|
||||
// 标题
|
||||
sheet.mergeCells('A1:C1');
|
||||
const titleCell = sheet.getCell('A1');
|
||||
titleCell.value = '全文复筛PRISMA统计';
|
||||
titleCell.font = { size: 16, bold: true };
|
||||
titleCell.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
titleCell.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FF2E86AB' },
|
||||
};
|
||||
titleCell.font = { size: 16, bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
sheet.getRow(1).height = 30;
|
||||
|
||||
// 总体统计
|
||||
let currentRow = 3;
|
||||
sheet.addRow(['统计项', '数量', '百分比']);
|
||||
sheet.getRow(currentRow).font = { bold: true };
|
||||
sheet.getRow(currentRow).fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFD0D0D0' },
|
||||
};
|
||||
|
||||
currentRow++;
|
||||
sheet.addRow(['全文复筛总数', total, '100%']);
|
||||
sheet.addRow(['最终纳入', included, `${((included / total) * 100).toFixed(1)}%`]);
|
||||
sheet.addRow(['最终排除', excluded, `${((excluded / total) * 100).toFixed(1)}%`]);
|
||||
sheet.addRow(['待审核', pending, `${((pending / total) * 100).toFixed(1)}%`]);
|
||||
sheet.addRow(['模型冲突数', conflictCount, `${((conflictCount / total) * 100).toFixed(1)}%`]);
|
||||
sheet.addRow(['人工审核数', reviewedCount, `${((reviewedCount / total) * 100).toFixed(1)}%`]);
|
||||
|
||||
// 空行
|
||||
currentRow += 7;
|
||||
sheet.addRow([]);
|
||||
|
||||
// 排除原因详细统计
|
||||
currentRow++;
|
||||
sheet.addRow(['排除原因', '数量', '占排除比例']);
|
||||
sheet.getRow(currentRow).font = { bold: true };
|
||||
sheet.getRow(currentRow).fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFD0D0D0' },
|
||||
};
|
||||
|
||||
currentRow++;
|
||||
Object.entries(exclusionReasons)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.forEach(([reason, count]) => {
|
||||
sheet.addRow([
|
||||
reason,
|
||||
count,
|
||||
excluded > 0 ? `${((count / excluded) * 100).toFixed(1)}%` : '0%',
|
||||
]);
|
||||
});
|
||||
|
||||
// 设置数字列格式
|
||||
sheet.getColumn(2).numFmt = '0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sheet 4: 成本统计
|
||||
*/
|
||||
private async createCostSheet(
|
||||
workbook: ExcelJS.Workbook,
|
||||
task: any,
|
||||
results: any[]
|
||||
) {
|
||||
const sheet = workbook.addWorksheet('成本统计');
|
||||
|
||||
// 设置列宽
|
||||
sheet.getColumn(1).width = 30;
|
||||
sheet.getColumn(2).width = 25;
|
||||
|
||||
// 标题
|
||||
sheet.mergeCells('A1:B1');
|
||||
const titleCell = sheet.getCell('A1');
|
||||
titleCell.value = '全文复筛成本统计';
|
||||
titleCell.font = { size: 16, bold: true };
|
||||
titleCell.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
titleCell.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FF27AE60' },
|
||||
};
|
||||
titleCell.font = { size: 16, bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
sheet.getRow(1).height = 30;
|
||||
|
||||
// 成本数据
|
||||
const totalTokens = task.totalTokens || 0;
|
||||
const totalCost = task.totalCost || 0;
|
||||
const processedCount = task.processedCount || 1;
|
||||
const avgCostPerLit = processedCount > 0 ? totalCost / processedCount : 0;
|
||||
const avgTokensPerLit = processedCount > 0 ? Math.round(totalTokens / processedCount) : 0;
|
||||
|
||||
// 时间统计
|
||||
const startedAt = task.startedAt ? new Date(task.startedAt) : null;
|
||||
const completedAt = task.completedAt ? new Date(task.completedAt) : new Date();
|
||||
const totalTimeMs = startedAt ? completedAt.getTime() - startedAt.getTime() : 0;
|
||||
const totalTimeSeconds = Math.round(totalTimeMs / 1000);
|
||||
const avgTimePerLit = processedCount > 0 ? Math.round(totalTimeMs / processedCount / 1000) : 0;
|
||||
|
||||
// 填充数据
|
||||
let currentRow = 3;
|
||||
sheet.addRow(['项目', '值']);
|
||||
sheet.getRow(currentRow).font = { bold: true };
|
||||
sheet.getRow(currentRow).fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFD0D0D0' },
|
||||
};
|
||||
|
||||
currentRow++;
|
||||
sheet.addRow(['模型组合', `${task.modelA} + ${task.modelB}`]);
|
||||
sheet.addRow(['处理文献数', processedCount]);
|
||||
sheet.addRow(['成功处理数', task.successCount || 0]);
|
||||
sheet.addRow(['降级处理数', task.degradedCount || 0]);
|
||||
sheet.addRow(['失败处理数', task.failedCount || 0]);
|
||||
sheet.addRow([]);
|
||||
|
||||
sheet.addRow(['Token使用统计', '']);
|
||||
sheet.getRow(currentRow + 6).font = { bold: true };
|
||||
sheet.addRow(['总Token数', totalTokens.toLocaleString()]);
|
||||
sheet.addRow(['平均Token/篇', avgTokensPerLit.toLocaleString()]);
|
||||
sheet.addRow([]);
|
||||
|
||||
sheet.addRow(['成本统计', '']);
|
||||
sheet.getRow(currentRow + 10).font = { bold: true };
|
||||
sheet.addRow(['总成本(元)', `¥${totalCost.toFixed(4)}`]);
|
||||
sheet.addRow(['平均成本/篇(元)', `¥${avgCostPerLit.toFixed(4)}`]);
|
||||
sheet.addRow([]);
|
||||
|
||||
sheet.addRow(['时间统计', '']);
|
||||
sheet.getRow(currentRow + 14).font = { bold: true };
|
||||
sheet.addRow(['总处理时间', `${Math.floor(totalTimeSeconds / 60)}分${totalTimeSeconds % 60}秒`]);
|
||||
sheet.addRow(['平均时间/篇', `${avgTimePerLit}秒`]);
|
||||
sheet.addRow(['开始时间', startedAt ? startedAt.toLocaleString('zh-CN') : '-']);
|
||||
sheet.addRow(['完成时间', completedAt ? completedAt.toLocaleString('zh-CN') : '-']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,715 @@
|
||||
/**
|
||||
* 全文复筛服务
|
||||
*
|
||||
* 功能:
|
||||
* - 批量处理文献全文筛选
|
||||
* - 集成LLM服务、验证器、冲突检测
|
||||
* - 并发控制与进度跟踪
|
||||
* - 容错与重试机制
|
||||
*
|
||||
* @module FulltextScreeningService
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import PQueue from 'p-queue';
|
||||
import { LLM12FieldsService, LLM12FieldsMode } from '../../common/llm/LLM12FieldsService.js';
|
||||
import { MedicalLogicValidator } from '../../common/validation/MedicalLogicValidator.js';
|
||||
import { EvidenceChainValidator } from '../../common/validation/EvidenceChainValidator.js';
|
||||
import { ConflictDetectionService } from '../../common/validation/ConflictDetectionService.js';
|
||||
import { logger } from '../../../../common/logging/index.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// =====================================================
|
||||
// 类型定义
|
||||
// =====================================================
|
||||
|
||||
export interface FulltextScreeningConfig {
|
||||
modelA: string;
|
||||
modelB: string;
|
||||
promptVersion?: string;
|
||||
concurrency?: number; // 并发数,默认3
|
||||
maxRetries?: number; // 最大重试次数,默认2
|
||||
skipExtraction?: boolean; // 跳过全文提取(用于测试)
|
||||
}
|
||||
|
||||
export interface ScreeningProgress {
|
||||
taskId: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
totalCount: number;
|
||||
processedCount: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
degradedCount: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
estimatedEndAt: Date | null;
|
||||
currentLiterature?: string;
|
||||
}
|
||||
|
||||
export interface SingleLiteratureResult {
|
||||
success: boolean;
|
||||
isDegraded: boolean;
|
||||
error?: string;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 全文复筛服务
|
||||
// =====================================================
|
||||
|
||||
export class FulltextScreeningService {
|
||||
private llmService: LLM12FieldsService;
|
||||
private medicalLogicValidator: MedicalLogicValidator;
|
||||
private evidenceChainValidator: EvidenceChainValidator;
|
||||
private conflictDetectionService: ConflictDetectionService;
|
||||
|
||||
constructor() {
|
||||
this.llmService = new LLM12FieldsService();
|
||||
this.medicalLogicValidator = new MedicalLogicValidator();
|
||||
this.evidenceChainValidator = new EvidenceChainValidator();
|
||||
this.conflictDetectionService = new ConflictDetectionService();
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 1. 任务处理入口
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 启动全文复筛任务
|
||||
*
|
||||
* @param projectId - 项目ID
|
||||
* @param literatureIds - 文献ID列表
|
||||
* @param config - 筛选配置
|
||||
* @returns 任务ID
|
||||
*/
|
||||
async createAndProcessTask(
|
||||
projectId: string,
|
||||
literatureIds: string[],
|
||||
config: FulltextScreeningConfig
|
||||
): Promise<string> {
|
||||
logger.info('Creating fulltext screening task', {
|
||||
projectId,
|
||||
literatureCount: literatureIds.length,
|
||||
config,
|
||||
});
|
||||
|
||||
// 1. 获取项目和文献数据
|
||||
const project = await prisma.aslScreeningProject.findUnique({
|
||||
where: { id: projectId },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Error(`Project not found: ${projectId}`);
|
||||
}
|
||||
|
||||
const literatures = await prisma.aslLiterature.findMany({
|
||||
where: {
|
||||
id: { in: literatureIds },
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (literatures.length === 0) {
|
||||
throw new Error('No valid literatures found');
|
||||
}
|
||||
|
||||
logger.info(`Found ${literatures.length} literatures to process`);
|
||||
|
||||
// 2. 创建任务记录
|
||||
const task = await prisma.aslFulltextScreeningTask.create({
|
||||
data: {
|
||||
id: `task_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
|
||||
projectId,
|
||||
modelA: config.modelA,
|
||||
modelB: config.modelB,
|
||||
promptVersion: config.promptVersion || 'v1.0.0-mvp',
|
||||
status: 'pending',
|
||||
totalCount: literatures.length,
|
||||
processedCount: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
degradedCount: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Task created: ${task.id}`);
|
||||
|
||||
// 3. 异步处理任务(不等待完成)
|
||||
this.processTaskInBackground(task.id, literatures, project, config).catch((error) => {
|
||||
logger.error('Task processing failed', { taskId: task.id, error });
|
||||
});
|
||||
|
||||
return task.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台处理任务(核心逻辑)
|
||||
*
|
||||
* @param taskId - 任务ID
|
||||
* @param literatures - 文献列表
|
||||
* @param project - 项目信息
|
||||
* @param config - 筛选配置
|
||||
*/
|
||||
private async processTaskInBackground(
|
||||
taskId: string,
|
||||
literatures: any[],
|
||||
project: any,
|
||||
config: FulltextScreeningConfig
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// 1. 更新任务状态为运行中
|
||||
await prisma.aslFulltextScreeningTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: 'running',
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Task started: ${taskId}`, {
|
||||
totalCount: literatures.length,
|
||||
concurrency: config.concurrency || 3,
|
||||
});
|
||||
|
||||
// 2. 构建PICOS上下文
|
||||
const picosContext = {
|
||||
P: project.picoCriteria.P || '',
|
||||
I: project.picoCriteria.I || '',
|
||||
C: project.picoCriteria.C || '',
|
||||
O: project.picoCriteria.O || '',
|
||||
S: project.picoCriteria.S || '',
|
||||
inclusionCriteria: project.inclusionCriteria || '',
|
||||
exclusionCriteria: project.exclusionCriteria || '',
|
||||
};
|
||||
|
||||
// 3. 并发处理文献
|
||||
const concurrency = config.concurrency || 3;
|
||||
const queue = new PQueue({ concurrency });
|
||||
|
||||
let processedCount = 0;
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
let degradedCount = 0;
|
||||
let totalTokens = 0;
|
||||
let totalCost = 0;
|
||||
|
||||
const tasks = literatures.map((literature, index) =>
|
||||
queue.add(async () => {
|
||||
const litStartTime = Date.now();
|
||||
logger.info(`[${index + 1}/${literatures.length}] Processing: ${literature.title}`);
|
||||
|
||||
try {
|
||||
// 处理单篇文献
|
||||
const result = await this.screenLiteratureWithRetry(
|
||||
taskId,
|
||||
project.id,
|
||||
literature,
|
||||
picosContext,
|
||||
config
|
||||
);
|
||||
|
||||
// 更新统计
|
||||
processedCount++;
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
if (result.isDegraded) {
|
||||
degradedCount++;
|
||||
}
|
||||
} else {
|
||||
failedCount++;
|
||||
}
|
||||
totalTokens += result.tokens;
|
||||
totalCost += result.cost;
|
||||
|
||||
// 更新进度
|
||||
await this.updateTaskProgress(taskId, {
|
||||
processedCount,
|
||||
successCount,
|
||||
failedCount,
|
||||
degradedCount,
|
||||
totalTokens,
|
||||
totalCost,
|
||||
startTime,
|
||||
});
|
||||
|
||||
const litDuration = Date.now() - litStartTime;
|
||||
logger.info(
|
||||
`[${index + 1}/${literatures.length}] ✅ Success: ${literature.title} (${litDuration}ms, ${result.tokens} tokens, $${result.cost.toFixed(4)})`
|
||||
);
|
||||
} catch (error: any) {
|
||||
processedCount++;
|
||||
failedCount++;
|
||||
|
||||
logger.error(`[${index + 1}/${literatures.length}] ❌ Failed: ${literature.title}`, {
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
// 更新进度(失败)
|
||||
await this.updateTaskProgress(taskId, {
|
||||
processedCount,
|
||||
successCount,
|
||||
failedCount,
|
||||
degradedCount,
|
||||
totalTokens,
|
||||
totalCost,
|
||||
startTime,
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 等待所有任务完成
|
||||
await Promise.all(tasks);
|
||||
|
||||
// 4. 完成任务
|
||||
await this.completeTask(taskId, {
|
||||
status: 'completed',
|
||||
totalTokens,
|
||||
totalCost,
|
||||
successCount,
|
||||
failedCount,
|
||||
degradedCount,
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info(`Task completed: ${taskId}`, {
|
||||
duration: `${(duration / 1000).toFixed(1)}s`,
|
||||
totalCount: literatures.length,
|
||||
successCount,
|
||||
failedCount,
|
||||
degradedCount,
|
||||
totalTokens,
|
||||
totalCost: `$${totalCost.toFixed(4)}`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`Task failed: ${taskId}`, { error: error.message, stack: error.stack });
|
||||
|
||||
await prisma.aslFulltextScreeningTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: 'failed',
|
||||
completedAt: new Date(),
|
||||
errorMessage: error.message,
|
||||
errorStack: error.stack,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 2. 单篇文献筛选
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 处理单篇文献(带重试)
|
||||
*
|
||||
* @param taskId - 任务ID
|
||||
* @param projectId - 项目ID
|
||||
* @param literature - 文献信息
|
||||
* @param picosContext - PICOS上下文
|
||||
* @param config - 筛选配置
|
||||
* @returns 处理结果
|
||||
*/
|
||||
private async screenLiteratureWithRetry(
|
||||
taskId: string,
|
||||
projectId: string,
|
||||
literature: any,
|
||||
picosContext: any,
|
||||
config: FulltextScreeningConfig
|
||||
): Promise<SingleLiteratureResult> {
|
||||
const maxRetries = config.maxRetries || 2;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await this.screenLiterature(taskId, projectId, literature, picosContext, config);
|
||||
} catch (error: any) {
|
||||
logger.warn(`Retry ${attempt}/${maxRetries} for literature ${literature.id}`, {
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
if (attempt === maxRetries) {
|
||||
// 最后一次重试失败,抛出错误
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 等待后重试(指数退避)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unreachable code');
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单篇文献(核心逻辑)
|
||||
*
|
||||
* @param taskId - 任务ID
|
||||
* @param projectId - 项目ID
|
||||
* @param literature - 文献信息
|
||||
* @param picosContext - PICOS上下文
|
||||
* @param config - 筛选配置
|
||||
* @returns 处理结果
|
||||
*/
|
||||
private async screenLiterature(
|
||||
taskId: string,
|
||||
projectId: string,
|
||||
literature: any,
|
||||
picosContext: any,
|
||||
config: FulltextScreeningConfig
|
||||
): Promise<SingleLiteratureResult> {
|
||||
// 1. 获取PDF Buffer
|
||||
let pdfBuffer: Buffer;
|
||||
let filename: string;
|
||||
|
||||
if (config.skipExtraction) {
|
||||
// 测试模式:创建一个简单的文本Buffer模拟PDF
|
||||
const testContent = `# ${literature.title}\n\n## Abstract\n${literature.abstract}`;
|
||||
pdfBuffer = Buffer.from(testContent, 'utf-8');
|
||||
filename = `test_${literature.id}.txt`;
|
||||
logger.info(`[TEST MODE] Using title+abstract as test PDF`);
|
||||
} else {
|
||||
// 生产模式:从存储中获取PDF
|
||||
if (!literature.pdfStorageRef) {
|
||||
throw new Error(`No PDF available for literature ${literature.id}`);
|
||||
}
|
||||
// TODO: 从OSS/Dify加载PDF Buffer
|
||||
// pdfBuffer = await pdfStorageService.downloadPDF(literature.pdfStorageRef);
|
||||
// 临时方案:使用测试数据
|
||||
const testContent = `# ${literature.title}\n\n## Abstract\n${literature.abstract}`;
|
||||
pdfBuffer = Buffer.from(testContent, 'utf-8');
|
||||
filename = `${literature.id}.pdf`;
|
||||
logger.warn(`[TODO] PDF loading not implemented, using test data for ${literature.id}`);
|
||||
}
|
||||
|
||||
// 2. 调用LLM服务(双模型)
|
||||
const llmResult = await this.llmService.processDualModels(
|
||||
LLM12FieldsMode.SCREENING,
|
||||
config.modelA,
|
||||
config.modelB,
|
||||
pdfBuffer,
|
||||
filename,
|
||||
picosContext
|
||||
);
|
||||
|
||||
// 检查至少有一个模型成功
|
||||
if (!llmResult.resultA && !llmResult.resultB) {
|
||||
throw new Error(`Both models failed in dual-model processing`);
|
||||
}
|
||||
|
||||
// 3. 验证器处理
|
||||
// 3.1 医学逻辑验证
|
||||
const medicalLogicIssuesA = llmResult.resultA?.result
|
||||
? this.medicalLogicValidator.validate(llmResult.resultA.result)
|
||||
: [];
|
||||
const medicalLogicIssuesB = llmResult.resultB?.result
|
||||
? this.medicalLogicValidator.validate(llmResult.resultB.result)
|
||||
: [];
|
||||
|
||||
// 3.2 证据链验证
|
||||
const evidenceChainIssuesA = llmResult.resultA?.result
|
||||
? this.evidenceChainValidator.validate(llmResult.resultA.result)
|
||||
: [];
|
||||
const evidenceChainIssuesB = llmResult.resultB?.result
|
||||
? this.evidenceChainValidator.validate(llmResult.resultB.result)
|
||||
: [];
|
||||
|
||||
// 3.3 冲突检测
|
||||
let conflictResult = null;
|
||||
if (llmResult.resultA?.result && llmResult.resultB?.result) {
|
||||
conflictResult = this.conflictDetectionService.detectScreeningConflict(
|
||||
llmResult.resultA.result,
|
||||
llmResult.resultB.result
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 保存结果到数据库
|
||||
const totalTokens = (llmResult.resultA?.tokenUsage || 0) + (llmResult.resultB?.tokenUsage || 0);
|
||||
const totalCost = (llmResult.resultA?.cost || 0) + (llmResult.resultB?.cost || 0);
|
||||
|
||||
await prisma.aslFulltextScreeningResult.create({
|
||||
data: {
|
||||
id: `result_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
|
||||
taskId,
|
||||
projectId,
|
||||
literatureId: literature.id,
|
||||
|
||||
// Model A 结果
|
||||
modelAName: config.modelA,
|
||||
modelAStatus: llmResult.resultA ? 'success' : 'failed',
|
||||
modelAFields: llmResult.resultA?.result?.fields || null,
|
||||
modelAOverall: llmResult.resultA?.result?.overall || null,
|
||||
modelAProcessingLog: llmResult.resultA?.result?.processingLog || null,
|
||||
modelAVerification: llmResult.resultA?.result?.verification || null,
|
||||
modelATokens: llmResult.resultA?.tokenUsage || 0,
|
||||
modelACost: llmResult.resultA?.cost || 0,
|
||||
modelAError: null,
|
||||
|
||||
// Model B 结果
|
||||
modelBName: config.modelB,
|
||||
modelBStatus: llmResult.resultB ? 'success' : 'failed',
|
||||
modelBFields: llmResult.resultB?.result?.fields || null,
|
||||
modelBOverall: llmResult.resultB?.result?.overall || null,
|
||||
modelBProcessingLog: llmResult.resultB?.result?.processingLog || null,
|
||||
modelBVerification: llmResult.resultB?.result?.verification || null,
|
||||
modelBTokens: llmResult.resultB?.tokenUsage || 0,
|
||||
modelBCost: llmResult.resultB?.cost || 0,
|
||||
modelBError: null,
|
||||
|
||||
// 验证结果
|
||||
medicalLogicIssues: {
|
||||
modelA: medicalLogicIssuesA,
|
||||
modelB: medicalLogicIssuesB,
|
||||
},
|
||||
evidenceChainIssues: {
|
||||
modelA: evidenceChainIssuesA,
|
||||
modelB: evidenceChainIssuesB,
|
||||
},
|
||||
|
||||
// 冲突检测
|
||||
isConflict: conflictResult ? conflictResult.hasConflict : false,
|
||||
conflictSeverity: conflictResult?.severity || null,
|
||||
conflictFields: conflictResult?.conflictFields || [],
|
||||
conflictDetails: conflictResult || null,
|
||||
reviewPriority: conflictResult?.reviewPriority || 50,
|
||||
|
||||
// 处理状态
|
||||
processingStatus: 'completed',
|
||||
isDegraded: llmResult.degradedMode || false,
|
||||
degradedModel: llmResult.failedModel || null,
|
||||
processedAt: new Date(),
|
||||
promptVersion: config.promptVersion || 'v1.0.0-mvp',
|
||||
|
||||
// 原始输出(用于审计)
|
||||
rawOutputA: llmResult.resultA || null,
|
||||
rawOutputB: llmResult.resultB || null,
|
||||
},
|
||||
});
|
||||
|
||||
// 5. 返回结果
|
||||
return {
|
||||
success: true,
|
||||
isDegraded: llmResult.degradedMode || false,
|
||||
tokens: totalTokens,
|
||||
cost: totalCost,
|
||||
};
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 3. 进度更新
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 更新任务进度
|
||||
*
|
||||
* @param taskId - 任务ID
|
||||
* @param progress - 进度信息
|
||||
*/
|
||||
private async updateTaskProgress(
|
||||
taskId: string,
|
||||
progress: {
|
||||
processedCount: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
degradedCount: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
startTime: number;
|
||||
}
|
||||
): Promise<void> {
|
||||
// 计算预估结束时间
|
||||
const elapsed = Date.now() - progress.startTime;
|
||||
const avgTimePerItem = elapsed / progress.processedCount;
|
||||
|
||||
const task = await prisma.aslFulltextScreeningTask.findUnique({
|
||||
where: { id: taskId },
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
logger.warn(`Task not found: ${taskId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingItems = task.totalCount - progress.processedCount;
|
||||
const estimatedRemainingTime = avgTimePerItem * remainingItems;
|
||||
const estimatedEndAt = new Date(Date.now() + estimatedRemainingTime);
|
||||
|
||||
// 更新数据库
|
||||
await prisma.aslFulltextScreeningTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
processedCount: progress.processedCount,
|
||||
successCount: progress.successCount,
|
||||
failedCount: progress.failedCount,
|
||||
degradedCount: progress.degradedCount,
|
||||
totalTokens: progress.totalTokens,
|
||||
totalCost: progress.totalCost,
|
||||
estimatedEndAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 4. 任务完成
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 标记任务完成
|
||||
*
|
||||
* @param taskId - 任务ID
|
||||
* @param summary - 任务摘要
|
||||
*/
|
||||
private async completeTask(
|
||||
taskId: string,
|
||||
summary: {
|
||||
status: 'completed' | 'failed';
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
degradedCount: number;
|
||||
}
|
||||
): Promise<void> {
|
||||
await prisma.aslFulltextScreeningTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: summary.status,
|
||||
completedAt: new Date(),
|
||||
totalTokens: summary.totalTokens,
|
||||
totalCost: summary.totalCost,
|
||||
successCount: summary.successCount,
|
||||
failedCount: summary.failedCount,
|
||||
degradedCount: summary.degradedCount,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Task marked as ${summary.status}: ${taskId}`);
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 5. 查询接口
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 获取任务进度
|
||||
*
|
||||
* @param taskId - 任务ID
|
||||
* @returns 进度信息
|
||||
*/
|
||||
async getTaskProgress(taskId: string): Promise<ScreeningProgress | null> {
|
||||
const task = await prisma.aslFulltextScreeningTask.findUnique({
|
||||
where: { id: taskId },
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
status: task.status as any,
|
||||
totalCount: task.totalCount,
|
||||
processedCount: task.processedCount,
|
||||
successCount: task.successCount,
|
||||
failedCount: task.failedCount,
|
||||
degradedCount: task.degradedCount,
|
||||
totalTokens: task.totalTokens || 0,
|
||||
totalCost: task.totalCost || 0,
|
||||
startedAt: task.startedAt,
|
||||
completedAt: task.completedAt,
|
||||
estimatedEndAt: task.estimatedEndAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务结果列表
|
||||
*
|
||||
* @param taskId - 任务ID
|
||||
* @param filter - 过滤条件
|
||||
* @returns 结果列表
|
||||
*/
|
||||
async getTaskResults(
|
||||
taskId: string,
|
||||
filter?: {
|
||||
conflictOnly?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
): Promise<{ results: any[]; total: number }> {
|
||||
const page = filter?.page || 1;
|
||||
const pageSize = filter?.pageSize || 50;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = { taskId };
|
||||
if (filter?.conflictOnly) {
|
||||
where.isConflict = true;
|
||||
}
|
||||
|
||||
const [results, total] = await Promise.all([
|
||||
prisma.aslFulltextScreeningResult.findMany({
|
||||
where,
|
||||
include: {
|
||||
literature: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
authors: true,
|
||||
journal: true,
|
||||
publicationYear: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ isConflict: 'desc' },
|
||||
{ reviewPriority: 'desc' },
|
||||
{ processedAt: 'desc' },
|
||||
],
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.aslFulltextScreeningResult.count({ where }),
|
||||
]);
|
||||
|
||||
return { results, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新人工复核决策
|
||||
*
|
||||
* @param resultId - 结果ID
|
||||
* @param decision - 决策信息
|
||||
*/
|
||||
async updateReviewDecision(
|
||||
resultId: string,
|
||||
decision: {
|
||||
finalDecision: 'include' | 'exclude';
|
||||
finalDecisionBy: string;
|
||||
exclusionReason?: string;
|
||||
reviewNotes?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
await prisma.aslFulltextScreeningResult.update({
|
||||
where: { id: resultId },
|
||||
data: {
|
||||
finalDecision: decision.finalDecision,
|
||||
finalDecisionBy: decision.finalDecisionBy,
|
||||
finalDecisionAt: new Date(),
|
||||
exclusionReason: decision.exclusionReason || null,
|
||||
reviewNotes: decision.reviewNotes || null,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Review decision updated: ${resultId}`, { decision: decision.finalDecision });
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const fulltextScreeningService = new FulltextScreeningService();
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* FulltextScreeningService 集成测试
|
||||
*
|
||||
* 测试场景:
|
||||
* 1. 创建任务并处理(使用测试模式,跳过PDF提取)
|
||||
* 2. 查询任务进度
|
||||
* 3. 查询任务结果
|
||||
* 4. 更新人工复核决策
|
||||
*/
|
||||
|
||||
import { FulltextScreeningService } from '../FulltextScreeningService.js';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const service = new FulltextScreeningService();
|
||||
|
||||
async function runIntegrationTest() {
|
||||
console.log('🚀 Starting FulltextScreeningService Integration Test\n');
|
||||
|
||||
try {
|
||||
// ==========================================
|
||||
// 1. 准备测试数据
|
||||
// ==========================================
|
||||
console.log('📋 Step 1: Preparing test data...');
|
||||
|
||||
// 查找一个现有项目
|
||||
const project = await prisma.aslScreeningProject.findFirst({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Error('No project found. Please create a project first.');
|
||||
}
|
||||
|
||||
console.log(`✅ Found project: ${project.projectName} (${project.id})`);
|
||||
|
||||
// 查找该项目的文献
|
||||
const literatures = await prisma.aslLiterature.findMany({
|
||||
where: { projectId: project.id },
|
||||
take: 3, // 只测试3篇
|
||||
});
|
||||
|
||||
if (literatures.length === 0) {
|
||||
throw new Error('No literatures found. Please import literatures first.');
|
||||
}
|
||||
|
||||
console.log(`✅ Found ${literatures.length} literatures to process`);
|
||||
literatures.forEach((lit, idx) => {
|
||||
console.log(` ${idx + 1}. ${lit.title.slice(0, 60)}...`);
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 2. 创建并处理任务
|
||||
// ==========================================
|
||||
console.log('\n📋 Step 2: Creating and processing task...');
|
||||
|
||||
const literatureIds = literatures.map((lit) => lit.id);
|
||||
|
||||
const taskId = await service.createAndProcessTask(
|
||||
project.id,
|
||||
literatureIds,
|
||||
{
|
||||
modelA: 'deepseek-chat',
|
||||
modelB: 'qwen-max',
|
||||
promptVersion: 'v1.0.0-mvp-test',
|
||||
concurrency: 2, // 并发2个
|
||||
maxRetries: 1,
|
||||
skipExtraction: true, // ⭐ 测试模式:跳过PDF提取,使用标题+摘要
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`✅ Task created: ${taskId}`);
|
||||
console.log('⏳ Task is processing in background...');
|
||||
|
||||
// ==========================================
|
||||
// 3. 轮询任务进度
|
||||
// ==========================================
|
||||
console.log('\n📋 Step 3: Monitoring task progress...');
|
||||
|
||||
let progress = await service.getTaskProgress(taskId);
|
||||
let iterations = 0;
|
||||
const maxIterations = 60; // 最多等待60次(约5分钟)
|
||||
|
||||
while (progress && progress.status === 'running' && iterations < maxIterations) {
|
||||
const percentage = ((progress.processedCount / progress.totalCount) * 100).toFixed(1);
|
||||
console.log(
|
||||
` Progress: ${progress.processedCount}/${progress.totalCount} (${percentage}%) | ` +
|
||||
`Success: ${progress.successCount} | Failed: ${progress.failedCount} | ` +
|
||||
`Degraded: ${progress.degradedCount} | ` +
|
||||
`Tokens: ${progress.totalTokens} | Cost: $${progress.totalCost.toFixed(4)}`
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000)); // 每5秒查询一次
|
||||
progress = await service.getTaskProgress(taskId);
|
||||
iterations++;
|
||||
}
|
||||
|
||||
if (!progress) {
|
||||
throw new Error('Task not found');
|
||||
}
|
||||
|
||||
if (progress.status === 'completed') {
|
||||
console.log('\n✅ Task completed successfully!');
|
||||
} else if (progress.status === 'failed') {
|
||||
console.log('\n❌ Task failed!');
|
||||
} else {
|
||||
console.log('\n⏰ Task still running (timeout reached)');
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 4. 查询任务结果
|
||||
// ==========================================
|
||||
console.log('\n📋 Step 4: Fetching task results...');
|
||||
|
||||
const { results, total } = await service.getTaskResults(taskId, {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
console.log(`✅ Found ${total} results`);
|
||||
|
||||
results.forEach((result: any, idx: number) => {
|
||||
console.log(`\n Result ${idx + 1}: ${result.literature.title.slice(0, 60)}...`);
|
||||
console.log(` - Model A: ${result.modelAStatus} (${result.modelATokens} tokens)`);
|
||||
console.log(` - Model B: ${result.modelBStatus} (${result.modelBTokens} tokens)`);
|
||||
console.log(` - Conflict: ${result.isConflict ? 'YES ⚠️' : 'NO'}`);
|
||||
console.log(` - Degraded: ${result.isDegraded ? 'YES' : 'NO'}`);
|
||||
console.log(` - Priority: ${result.reviewPriority}`);
|
||||
|
||||
// 显示字段提取情况
|
||||
if (result.modelAFields) {
|
||||
const fieldCount = Object.keys(result.modelAFields).length;
|
||||
console.log(` - Model A Fields: ${fieldCount} extracted`);
|
||||
}
|
||||
if (result.modelBFields) {
|
||||
const fieldCount = Object.keys(result.modelBFields).length;
|
||||
console.log(` - Model B Fields: ${fieldCount} extracted`);
|
||||
}
|
||||
|
||||
// 显示验证问题
|
||||
if (result.medicalLogicIssues) {
|
||||
const issuesA = result.medicalLogicIssues.modelA?.length || 0;
|
||||
const issuesB = result.medicalLogicIssues.modelB?.length || 0;
|
||||
if (issuesA > 0 || issuesB > 0) {
|
||||
console.log(` - Medical Logic Issues: A=${issuesA}, B=${issuesB}`);
|
||||
}
|
||||
}
|
||||
if (result.evidenceChainIssues) {
|
||||
const issuesA = result.evidenceChainIssues.modelA?.length || 0;
|
||||
const issuesB = result.evidenceChainIssues.modelB?.length || 0;
|
||||
if (issuesA > 0 || issuesB > 0) {
|
||||
console.log(` - Evidence Chain Issues: A=${issuesA}, B=${issuesB}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 5. 测试人工复核决策(仅第一个结果)
|
||||
// ==========================================
|
||||
if (results.length > 0) {
|
||||
console.log('\n📋 Step 5: Testing review decision update...');
|
||||
|
||||
const firstResult = results[0];
|
||||
await service.updateReviewDecision(firstResult.id, {
|
||||
finalDecision: 'include',
|
||||
finalDecisionBy: 'test-user',
|
||||
reviewNotes: 'Test review decision from integration test',
|
||||
});
|
||||
|
||||
console.log(`✅ Review decision updated for result: ${firstResult.id}`);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 6. 总结
|
||||
// ==========================================
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('🎉 Integration Test Completed Successfully!');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`Task ID: ${taskId}`);
|
||||
console.log(`Status: ${progress.status}`);
|
||||
console.log(`Total Processed: ${progress.processedCount}/${progress.totalCount}`);
|
||||
console.log(`Success: ${progress.successCount}`);
|
||||
console.log(`Failed: ${progress.failedCount}`);
|
||||
console.log(`Degraded: ${progress.degradedCount}`);
|
||||
console.log(`Total Tokens: ${progress.totalTokens}`);
|
||||
console.log(`Total Cost: $${progress.totalCost.toFixed(4)}`);
|
||||
console.log(`Duration: ${calculateDuration(progress.startedAt, progress.completedAt)}`);
|
||||
console.log('='.repeat(60));
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('\n❌ Test failed:', error.message);
|
||||
console.error(error.stack);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function calculateDuration(start: Date | null, end: Date | null): string {
|
||||
if (!start || !end) return 'N/A';
|
||||
const duration = end.getTime() - start.getTime();
|
||||
const seconds = Math.floor(duration / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
runIntegrationTest();
|
||||
|
||||
Reference in New Issue
Block a user