fix(rvw): speed up review flow and complete forensics export

Run RVW skills in controlled parallel mode and persist per-skill progress so users can view completed tabs during execution.
Also include data-forensics tables in Word export and refresh the RVW module status documentation.

Made-with: Cursor
This commit is contained in:
2026-03-10 21:10:43 +08:00
parent d96cdf3fe8
commit 4a4771fbbe
7 changed files with 377 additions and 78 deletions

View File

@@ -39,6 +39,7 @@ const getProgressSteps = (selectedAgents: string[]) => {
const steps = [
{ key: 'upload', label: '上传文档' },
{ key: 'extract', label: '文本提取' },
{ key: 'forensics', label: '数据验证' },
];
if (selectedAgents.includes('editorial')) {
@@ -67,6 +68,17 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
const isCompleted = task.status === 'completed' || task.status === 'partial_completed';
const isPartial = task.status === 'partial_completed';
const isFailed = task.status === 'failed';
const hasIncrementalResult =
!!task.editorialReview || !!task.methodologyReview || !!task.forensicsResult || !!task.clinicalReview;
const displayReport: ReviewReport | null = report || (hasIncrementalResult
? {
...task,
editorialReview: task.editorialReview,
methodologyReview: task.methodologyReview,
forensicsResult: task.forensicsResult,
clinicalReview: task.clinicalReview,
}
: null);
// 轮询任务状态
useEffect(() => {
@@ -110,20 +122,20 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
}
}, [isCompleted, task.id, report]);
// 报告加载后自动设置正确的 Tab
// 报告(含增量结果)加载后自动设置可用 Tab
useEffect(() => {
if (report) {
if (report.editorialReview) {
if (displayReport) {
if (displayReport.editorialReview) {
setActiveTab('editorial');
} else if (report.methodologyReview) {
} else if (displayReport.methodologyReview) {
setActiveTab('methodology');
} else if (report.forensicsResult) {
} else if (displayReport.forensicsResult) {
setActiveTab('forensics');
} else if (report.clinicalReview) {
} else if (displayReport.clinicalReview) {
setActiveTab('clinical');
}
}
}, [report]);
}, [displayReport]);
// 动态获取进度步骤
const progressSteps = getProgressSteps(task.selectedAgents || ['editorial', 'methodology']);
@@ -132,6 +144,21 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
const getStepStatus = (stepKey: string): 'completed' | 'active' | 'pending' => {
const hasEditorial = task.selectedAgents?.includes('editorial');
const hasMethodology = task.selectedAgents?.includes('methodology');
const hasClinical = task.selectedAgents?.includes('clinical');
const skillMap: Record<string, string> = {
forensics: 'DataForensicsSkill',
editorial: 'EditorialSkill',
methodology: 'MethodologySkill',
clinical: 'ClinicalAssessmentSkill',
};
const currentSkill = stepKey in skillMap ? task.reviewProgress?.[skillMap[stepKey]] : undefined;
if (currentSkill?.status && ['success', 'warning', 'skipped'].includes(currentSkill.status)) {
return 'completed';
}
if (currentSkill?.status && ['error', 'timeout'].includes(currentSkill.status)) {
return 'completed';
}
if (task.status === 'pending') {
return stepKey === 'upload' ? 'completed' : 'pending';
@@ -143,7 +170,10 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
}
if (task.status === 'reviewing' || task.status === 'reviewing_editorial') {
if (['upload', 'extract'].includes(stepKey)) return 'completed';
if (stepKey === 'forensics') return 'active';
if (stepKey === 'editorial' && hasEditorial) return 'active';
if (stepKey === 'methodology' && hasMethodology) return 'active';
if (stepKey === 'clinical' && hasClinical) return 'active';
return 'pending';
}
if (task.status === 'reviewing_methodology') {
@@ -245,12 +275,20 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
);
children.push(new Paragraph({ spacing: { after: 300 } }));
let sectionIndex = 1;
const sectionNumbers = ['一', '二', '三', '四', '五', '六'];
const nextSectionTitle = (title: string) => {
const current = sectionNumbers[sectionIndex - 1] || `${sectionIndex}`;
sectionIndex += 1;
return `${current}${title}`;
};
// 稿约规范性评估
if (report.editorialReview) {
children.push(
new Paragraph({
text: '一、稿约规范性评估',
text: nextSectionTitle('稿约规范性评估'),
heading: HeadingLevel.HEADING_1,
spacing: { before: 400, after: 200 },
})
@@ -313,7 +351,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
if (report.methodologyReview) {
children.push(
new Paragraph({
text: '二、方法学评估',
text: nextSectionTitle('方法学评估'),
heading: HeadingLevel.HEADING_1,
spacing: { before: 400, after: 200 },
})
@@ -374,13 +412,152 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
}
});
}
// 数据验证
if (report.forensicsResult) {
children.push(
new Paragraph({
text: nextSectionTitle('数据验证'),
heading: HeadingLevel.HEADING_1,
spacing: { before: 400, after: 200 },
})
);
const summary = report.forensicsResult.summary;
children.push(
new Paragraph({
text: `共识别表格 ${summary.totalTables} 个;发现问题 ${summary.totalIssues} 个(错误 ${summary.errorCount},警告 ${summary.warningCount})。`,
spacing: { after: 160 },
})
);
// 导出表格明细(按 forensicsResult.tables 渲染)
if (report.forensicsResult.tables.length > 0) {
report.forensicsResult.tables.slice(0, 20).forEach((tableItem, tableIndex) => {
children.push(
new Paragraph({
text: `${tableIndex + 1}${tableItem.caption || tableItem.id || '未命名表格'}`,
heading: HeadingLevel.HEADING_2,
spacing: { before: 180, after: 100 },
})
);
const rows: TableRow[] = [];
const headers = tableItem.headers || [];
const tableData = tableItem.data || [];
if (headers.length > 0) {
rows.push(
new TableRow({
children: headers.map((header) => new TableCell({
children: [new Paragraph({ children: [new TextRun({ text: String(header ?? ''), bold: true })] })],
})),
})
);
}
// 限制导出行数,避免超大表格导致文档膨胀
tableData.slice(0, 200).forEach((row) => {
rows.push(
new TableRow({
children: row.map((cell) => new TableCell({
children: [new Paragraph(String(cell ?? ''))],
})),
})
);
});
if (rows.length > 0) {
children.push(
new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
rows,
})
);
} else {
children.push(
new Paragraph({
text: '(该表未提取到可导出的结构化数据)',
spacing: { after: 80 },
})
);
}
const tableIssues = (tableItem.issues || []).slice(0, 50);
if (tableIssues.length > 0) {
children.push(
new Paragraph({
children: [new TextRun({ text: '该表问题:', bold: true })],
spacing: { before: 80, after: 40 },
})
);
tableIssues.forEach((issue) => {
const level = issue.severity === 'ERROR' ? '错误' : issue.severity === 'WARNING' ? '警告' : '提示';
children.push(
new Paragraph({
text: `• [${level}] ${issue.message}`,
indent: { left: 720 },
spacing: { after: 40 },
})
);
});
}
});
if (report.forensicsResult.tables.length > 20) {
children.push(
new Paragraph({
text: `(其余 ${report.forensicsResult.tables.length - 20} 张表格已省略)`,
spacing: { before: 80, after: 80 },
})
);
}
}
if (report.forensicsResult.issues.length > 0) {
children.push(
new Paragraph({
children: [new TextRun({ text: '问题清单:', bold: true })],
spacing: { after: 80 },
})
);
report.forensicsResult.issues.slice(0, 120).forEach((issue) => {
const level = issue.severity === 'ERROR' ? '错误' : issue.severity === 'WARNING' ? '警告' : '提示';
children.push(
new Paragraph({
text: `• [${level}] ${issue.message}`,
indent: { left: 720 },
spacing: { after: 50 },
})
);
});
if (report.forensicsResult.issues.length > 120) {
children.push(
new Paragraph({
text: `(其余 ${report.forensicsResult.issues.length - 120} 条问题已省略)`,
indent: { left: 720 },
spacing: { after: 100 },
})
);
}
} else {
children.push(
new Paragraph({
children: [new TextRun({ text: '✓ 未发现数据一致性问题', color: '006600' })],
indent: { left: 720 },
spacing: { after: 100 },
})
);
}
}
// 临床专业评估
if (report.clinicalReview) {
const sectionNum = report.methodologyReview ? '三' : '二';
children.push(
new Paragraph({
text: `${sectionNum}临床专业评估`,
text: nextSectionTitle('临床专业评估'),
heading: HeadingLevel.HEADING_1,
spacing: { before: 400, after: 200 },
})
@@ -552,7 +729,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
{/* 提示信息 */}
<div className="text-center text-slate-500 text-sm bg-slate-50 rounded-lg py-4">
<p>AI 稿 1-3 </p>
<p className="text-slate-400 mt-1"></p>
<p className="text-slate-400 mt-1"></p>
</div>
</div>
)}
@@ -594,8 +771,8 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
</div>
)}
{/* 完成状态 - 显示报告 */}
{isCompleted && report && (
{/* 增量报告:处理中也可查看已完成模块 */}
{displayReport && (isProcessing || isCompleted || isPartial) && (
<>
{/* 审查完成信息 */}
<div className="bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl shadow-lg p-5 mb-8 text-white">
@@ -603,10 +780,10 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
<div className="flex items-center gap-3">
<CheckCircle className="w-6 h-6 text-white/90" />
<div>
<h2 className="text-lg font-semibold"></h2>
<h2 className="text-lg font-semibold">{isCompleted ? '审查完成' : '已完成模块结果(实时更新)'}</h2>
<p className="text-indigo-100 text-sm">
{report.durationSeconds ? formatTime(report.durationSeconds) : '-'}
{report.completedAt && ` · ${new Date(report.completedAt).toLocaleString('zh-CN')}`}
{displayReport.durationSeconds ? formatTime(displayReport.durationSeconds) : '-'}
{displayReport.completedAt && ` · ${new Date(displayReport.completedAt).toLocaleString('zh-CN')}`}
</p>
</div>
</div>
@@ -615,7 +792,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
{/* Tab切换 */}
<div className="flex gap-1 bg-slate-200/50 p-1 rounded-lg mb-6 w-fit mx-auto">
{report.editorialReview && (
{displayReport.editorialReview && (
<button
onClick={() => setActiveTab('editorial')}
className={`px-6 py-2 rounded-md text-sm transition-all ${
@@ -627,7 +804,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
稿
</button>
)}
{report.methodologyReview && (
{displayReport.methodologyReview && (
<button
onClick={() => setActiveTab('methodology')}
className={`px-6 py-2 rounded-md text-sm transition-all ${
@@ -639,7 +816,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
</button>
)}
{report.forensicsResult && (
{displayReport.forensicsResult && (
<button
onClick={() => setActiveTab('forensics')}
className={`px-6 py-2 rounded-md text-sm transition-all ${
@@ -648,10 +825,10 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
: 'font-medium text-slate-500 hover:text-slate-700'
}`}
>
({report.forensicsResult.summary.totalIssues || 0})
({displayReport.forensicsResult.summary.totalIssues || 0})
</button>
)}
{report.clinicalReview && (
{displayReport.clinicalReview && (
<button
onClick={() => setActiveTab('clinical')}
className={`px-6 py-2 rounded-md text-sm transition-all ${
@@ -666,7 +843,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
</div>
{/* 非 docx 文件无数据验证提示 */}
{!report.forensicsResult && (report.editorialReview || report.methodologyReview) && (() => {
{!displayReport.forensicsResult && (displayReport.editorialReview || displayReport.methodologyReview) && (() => {
const fileName = task.fileName || '';
const isPdf = fileName.toLowerCase().endsWith('.pdf');
const isDoc = fileName.toLowerCase().endsWith('.doc');
@@ -686,17 +863,17 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
})()}
{/* 报告内容 */}
{activeTab === 'editorial' && report.editorialReview && (
<EditorialReport data={report.editorialReview} />
{activeTab === 'editorial' && displayReport.editorialReview && (
<EditorialReport data={displayReport.editorialReview} />
)}
{activeTab === 'methodology' && report.methodologyReview && (
<MethodologyReport data={report.methodologyReview} />
{activeTab === 'methodology' && displayReport.methodologyReview && (
<MethodologyReport data={displayReport.methodologyReview} />
)}
{activeTab === 'forensics' && report.forensicsResult && (
<ForensicsReport data={report.forensicsResult} />
{activeTab === 'forensics' && displayReport.forensicsResult && (
<ForensicsReport data={displayReport.forensicsResult} />
)}
{activeTab === 'clinical' && report.clinicalReview && (
<ClinicalReport data={report.clinicalReview} />
{activeTab === 'clinical' && displayReport.clinicalReview && (
<ClinicalReport data={displayReport.clinicalReview} />
)}
</>
)}

View File

@@ -42,6 +42,16 @@ export interface ReviewTask {
timeoutCount: number;
totalSkills: number;
};
editorialReview?: EditorialReviewResult;
methodologyReview?: MethodologyReviewResult;
forensicsResult?: ForensicsResult;
clinicalReview?: ClinicalReviewResult;
reviewProgress?: Record<string, {
status: 'success' | 'warning' | 'error' | 'timeout' | 'skipped';
executionTime?: number;
completedAt?: string;
error?: string | null;
}>;
createdAt: string;
completedAt?: string;
durationSeconds?: number;