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

@@ -315,6 +315,11 @@ export async function getTaskDetail(
logger.info('[RVW:Controller] 获取任务详情', { taskId });
const task = await reviewService.getTaskDetail(userId, taskId);
const contextData = task.contextData as {
forensicsResult?: unknown;
clinicalReview?: unknown;
skillProgress?: Record<string, unknown>;
} | null;
// 🆕 直接使用新字段
return reply.send({
@@ -336,6 +341,11 @@ export async function getTaskDetail(
durationSeconds: task.durationSeconds,
errorMessage: task.errorMessage,
errorDetails: task.errorDetails ?? undefined,
editorialReview: task.editorialReview ?? undefined,
methodologyReview: task.methodologyReview ?? undefined,
forensicsResult: contextData?.forensicsResult ?? undefined,
clinicalReview: contextData?.clinicalReview ?? undefined,
reviewProgress: contextData?.skillProgress ?? undefined,
},
});
} catch (error) {

View File

@@ -125,18 +125,21 @@ export class SkillExecutor<TContext extends BaseSkillContext = SkillContext> {
taskId: context.taskId,
skillIds: stage.map(s => s.skillId),
});
const promises = stage.map(item => this.executePipelineItem(item, context, profile));
const stageConcurrency = Math.max(1, profile.globalConfig?.maxConcurrency ?? stage.length);
for (let offset = 0; offset < stage.length; offset += stageConcurrency) {
const chunk = stage.slice(offset, offset + stageConcurrency);
const promises = chunk.map(item => this.executePipelineItem(item, context, profile));
const settled = await Promise.allSettled(promises);
for (let i = 0; i < stage.length; i++) {
for (let i = 0; i < chunk.length; i++) {
const outcome = settled[i];
const currentItem = chunk[i];
if (outcome.status === 'fulfilled') {
const result = outcome.value;
if (result) {
results.push(result);
context.previousResults.push(result);
const skill = SkillRegistry.get(stage[i].skillId);
const skill = SkillRegistry.get(currentItem.skillId);
if (skill) this.updateContextWithResult(context, skill, result);
}
} else {
@@ -145,19 +148,19 @@ export class SkillExecutor<TContext extends BaseSkillContext = SkillContext> {
? outcome.reason.message
: String(outcome.reason);
logger.error('[SkillExecutor] Parallel skill promise rejected (uncaught)', {
skillId: stage[i].skillId,
skillId: currentItem.skillId,
taskId: context.taskId,
error: errorMessage,
});
const now = new Date();
results.push({
skillId: stage[i].skillId,
skillName: stage[i].skillId,
skillId: currentItem.skillId,
skillName: currentItem.skillId,
status: 'error',
issues: [{
severity: 'ERROR',
type: SkillErrorCodes.SKILL_EXECUTION_ERROR,
message: `${stage[i].skillId} 执行异常: ${errorMessage}`,
message: `${currentItem.skillId} 执行异常: ${errorMessage}`,
}],
error: errorMessage,
executionTime: 0,
@@ -168,6 +171,7 @@ export class SkillExecutor<TContext extends BaseSkillContext = SkillContext> {
}
}
}
}
const summary = this.buildSummary(context.taskId, profile.id, results, startTime);

View File

@@ -29,6 +29,7 @@ export const DEFAULT_PROFILE: JournalProfile = {
tolerancePercent: 0.1,
},
timeout: 300000, // 5min: Python + LLM核查(内部180s超时降级) + 长文档余量
parallelGroup: 'llm-review', // 与其余模块并行,缩短总时长
},
{
skillId: 'EditorialSkill',
@@ -57,6 +58,7 @@ export const DEFAULT_PROFILE: JournalProfile = {
strictness: 'STANDARD',
continueOnError: true,
timeoutMultiplier: 1.0,
maxConcurrency: 4, // 受控并行默认同时跑4个模块
},
};

View File

@@ -34,6 +34,7 @@ import {
createPartialContextFromTask,
registerBuiltinSkills,
ExecutionSummary,
SkillResult,
} from '../skills/index.js';
/**
@@ -307,6 +308,7 @@ export async function registerReviewWorker() {
},
forensicsResult: skillsSummary.results.find(r => r.skillId === 'DataForensicsSkill')?.data,
clinicalReview: clinicalResult,
skillProgress: buildSkillProgressMap(skillsSummary.results),
}
: null;
@@ -439,6 +441,19 @@ function buildErrorDetails(summary: ExecutionSummary): Record<string, unknown> {
};
}
function buildSkillProgressMap(results: SkillResult[]): Record<string, unknown> {
const progress: Record<string, unknown> = {};
for (const item of results) {
progress[item.skillId] = {
status: item.status,
executionTime: item.executionTime,
completedAt: item.completedAt.toISOString(),
error: item.error || null,
};
}
return progress;
}
/**
* 使用 V2.0 Skills 架构执行审查
*/
@@ -480,9 +495,73 @@ async function executeWithSkills(
fileSize,
});
const currentTask = await prisma.reviewTask.findUnique({
where: { id: taskId },
select: { contextData: true },
});
const incrementalContext =
((currentTask?.contextData as Record<string, unknown> | null) || {});
const runningContext: Record<string, unknown> = { ...incrementalContext };
const skillProgress =
((incrementalContext.skillProgress as Record<string, unknown> | undefined) || {});
let persistQueue: Promise<void> = Promise.resolve();
const persistSkillResult = (result: SkillResult) => {
persistQueue = persistQueue.then(async () => {
skillProgress[result.skillId] = {
status: result.status,
executionTime: result.executionTime,
completedAt: result.completedAt.toISOString(),
error: result.error || null,
};
const nextContext: Record<string, unknown> = {
...runningContext,
skillProgress: { ...skillProgress },
};
const updateData: Record<string, unknown> = {
contextData: nextContext as Prisma.InputJsonValue,
};
if (result.skillId === 'DataForensicsSkill' && result.data) {
nextContext.forensicsResult = result.data;
}
if (result.skillId === 'ClinicalAssessmentSkill' && result.data) {
nextContext.clinicalReview = result.data;
}
if (result.skillId === 'EditorialSkill' && result.data) {
const editorialData = result.data as EditorialReview;
updateData.editorialReview = editorialData as unknown as Prisma.InputJsonValue;
updateData.editorialScore = editorialData.overall_score ?? null;
}
if (result.skillId === 'MethodologySkill' && result.data) {
const methodologyData = result.data as MethodologyReview;
updateData.methodologyReview = methodologyData as unknown as Prisma.InputJsonValue;
updateData.methodologyScore = methodologyData.overall_score ?? null;
updateData.methodologyStatus = getMethodologyStatus(methodologyData);
}
updateData.contextData = nextContext as Prisma.InputJsonValue;
Object.assign(runningContext, nextContext);
await prisma.reviewTask.update({
where: { id: taskId },
data: updateData as Prisma.ReviewTaskUpdateInput,
});
});
return persistQueue;
};
// 执行 Pipeline
const executor = new SkillExecutor();
const executor = new SkillExecutor({
onSkillComplete: async (_skillId, result) => {
await persistSkillResult(result);
},
});
const summary = await executor.execute(profile, partialContext);
await persistQueue;
// 输出执行结果
console.log(`\n 📊 Skills 执行结果:`);

View File

@@ -1,10 +1,10 @@
# RVW稿件审查模块 - 当前状态与开发指南
> **文档版本:** v6.0
> **文档版本:** v6.1
> **创建日期:** 2026-01-07
> **最后更新:** 2026-03-07
> **最后更新:** 2026-03-10
> **维护者:** 开发团队
> **当前状态:** 🚀 **V3.0 "智能审稿增强" 完成LLM数据核查 + 临床评估 + 稳定性增强**
> **当前状态:** 🚀 **V3.0.1 "性能与体验增强" 完成4模块并行 + 增量展示 + 导出补全**
> **文档目的:** 快速了解RVW模块状态为新AI助手提供上下文
>
> **🎉 V3.0 进展2026-03-07**
@@ -15,6 +15,12 @@
> - ✅ **部分完成支持**:新增 `partial_completed` 状态 + `errorDetails` 字段,部分模块失败仍展示成功结果
> - ✅ **前端 4 Tab 报告**:稿约规范性 / 方法学 / 数据验证 / 临床评估Word 导出全覆盖
>
> **⚡ V3.0.1 增强2026-03-10**
> - ✅ **4模块受控并行**DataForensics 与 Editorial/Methodology/Clinical 同组并行,`maxConcurrency=4` 控制并发上限
> - ✅ **增量结果持久化**:每个 Skill 完成即写入任务中间结果,`getTaskDetail` 返回模块级 `reviewProgress`
> - ✅ **先出先看**TaskDetail 在审查中即可展示已完成模块(无需等待全流程结束)
> - ✅ **Word 导出修复**:补齐“数据验证”章节,导出汇总 + 表格明细 + 该表问题列表
>
> **V2.0 进展回顾:**
> - ✅ L1 算术验证 + L2 统计验证 + L2.5 一致性取证
> - ✅ Skills 核心框架types, registry, executor, profile
@@ -34,7 +40,7 @@
| **商业价值** | ⭐⭐⭐⭐⭐ 极高 |
| **独立性** | ⭐⭐⭐⭐⭐ 极高(用户群完全不同) |
| **目标用户** | 期刊初审编辑 |
| **开发状态** | ✅ **V3.0 完成4维审查(规范性+方法学+数据验证+临床评估)+ 稳定性增强 + Word导出** |
| **开发状态** | ✅ **V3.0.1 完成4维审查并行提速 + 增量结果展示 + Word导出补全** |
### 核心目标
@@ -105,7 +111,7 @@ backend/src/modules/rvw/
│ ├── clinicalService.ts # 🆕 V3.0 临床专业评估服务
│ └── utils.ts # 工具函数
├── workers/
│ └── reviewWorker.ts # pg-boss异步任务处理V2.0 Skills集成 + V3.0 partial_completed
│ └── reviewWorker.ts # pg-boss异步任务处理V2.0 Skills集成 + V3.0 partial_completed + V3.0.1 增量落库
├── skills/ # V2.0 Skills 架构
│ ├── core/ # 核心框架types, registry, executor[allSettled]等)
│ ├── library/ # Skill 实现Forensics[+LLM], Editorial, Methodology, 🆕Clinical
@@ -125,7 +131,7 @@ frontend-v2/src/modules/rvw/
├── Header.tsx # 页头(上传按钮)
├── Sidebar.tsx # 侧边栏导航
├── TaskTable.tsx # 任务列表表格(支持 partial_completed 状态)
├── TaskDetail.tsx # 任务详情(进度条+报告+Word导出+部分完成警告)
├── TaskDetail.tsx # 任务详情(进度条+增量展示+Word导出+部分完成警告)
├── EditorialReport.tsx # 稿约规范性报告
├── MethodologyReport.tsx # 方法学评估报告
├── ForensicsReport.tsx # 数据验证报告(含 LLM 核查结果)
@@ -446,6 +452,17 @@ Content-Type: multipart/form-data
| 前端 partial_completed UI | ✅ 已完成 | 琥珀色警告横幅 + 列表"部分完成"标签 |
| Word 导出覆盖临床评估 | ✅ 已完成 | 导出报告包含临床专业评估章节 |
### ⚡ V3.0.1 "性能与体验增强" 开发进度2026-03-10
| 任务 | 状态 | 说明 |
|------|------|------|
| DataForensics 并入并行组 | ✅ 已完成 | 与 Editorial/Methodology/Clinical 同组并行执行 |
| 并发上限控制 | ✅ 已完成 | Executor 支持按 `maxConcurrency` 分批并行 |
| 模块完成即持久化 | ✅ 已完成 | Worker 通过 `onSkillComplete` 增量写入 `contextData` |
| 任务详情增量返回 | ✅ 已完成 | `getTaskDetail` 返回模块结果与 `reviewProgress` |
| 前端先出先看 | ✅ 已完成 | 审查过程中实时展示已完成 Tab |
| Word 导出补齐数据验证 | ✅ 已完成 | 导出包含数据验证汇总、表格明细、该表问题列表 |
### 后续版本V3.1+
- [ ] 全面移除评分机制(只列问题,不打分)
@@ -462,7 +479,7 @@ Content-Type: multipart/form-data
---
**文档版本:** v6.0
**最后更新:** 2026-03-07
**当前状态:** 🚀 V3.0 "智能审稿增强" 完成LLM数据核查 + 临床评估 + 稳定性增强
**文档版本:** v6.1
**最后更新:** 2026-03-10
**当前状态:** 🚀 V3.0.1 "性能与体验增强" 完成4模块并行 + 增量展示 + 导出补全
**下一步:** V3.1 移除评分机制 + 单模块重试

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') {
@@ -246,11 +276,19 @@ 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 },
})
@@ -375,12 +413,151 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
});
}
// 临床专业评估
if (report.clinicalReview) {
const sectionNum = report.methodologyReview ? '三' : '二';
// 数据验证
if (report.forensicsResult) {
children.push(
new Paragraph({
text: `${sectionNum}、临床专业评估`,
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) {
children.push(
new Paragraph({
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;