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:
@@ -315,6 +315,11 @@ export async function getTaskDetail(
|
|||||||
logger.info('[RVW:Controller] 获取任务详情', { taskId });
|
logger.info('[RVW:Controller] 获取任务详情', { taskId });
|
||||||
|
|
||||||
const task = await reviewService.getTaskDetail(userId, 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({
|
return reply.send({
|
||||||
@@ -336,6 +341,11 @@ export async function getTaskDetail(
|
|||||||
durationSeconds: task.durationSeconds,
|
durationSeconds: task.durationSeconds,
|
||||||
errorMessage: task.errorMessage,
|
errorMessage: task.errorMessage,
|
||||||
errorDetails: task.errorDetails ?? undefined,
|
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) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -125,45 +125,49 @@ export class SkillExecutor<TContext extends BaseSkillContext = SkillContext> {
|
|||||||
taskId: context.taskId,
|
taskId: context.taskId,
|
||||||
skillIds: stage.map(s => s.skillId),
|
skillIds: stage.map(s => s.skillId),
|
||||||
});
|
});
|
||||||
|
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);
|
||||||
|
|
||||||
const promises = stage.map(item => this.executePipelineItem(item, context, profile));
|
for (let i = 0; i < chunk.length; i++) {
|
||||||
const settled = await Promise.allSettled(promises);
|
const outcome = settled[i];
|
||||||
|
const currentItem = chunk[i];
|
||||||
for (let i = 0; i < stage.length; i++) {
|
if (outcome.status === 'fulfilled') {
|
||||||
const outcome = settled[i];
|
const result = outcome.value;
|
||||||
if (outcome.status === 'fulfilled') {
|
if (result) {
|
||||||
const result = outcome.value;
|
results.push(result);
|
||||||
if (result) {
|
context.previousResults.push(result);
|
||||||
results.push(result);
|
const skill = SkillRegistry.get(currentItem.skillId);
|
||||||
context.previousResults.push(result);
|
if (skill) this.updateContextWithResult(context, skill, result);
|
||||||
const skill = SkillRegistry.get(stage[i].skillId);
|
}
|
||||||
if (skill) this.updateContextWithResult(context, skill, result);
|
} else {
|
||||||
|
// Promise 本身 rejected — 极端情况下的兜底
|
||||||
|
const errorMessage = outcome.reason instanceof Error
|
||||||
|
? outcome.reason.message
|
||||||
|
: String(outcome.reason);
|
||||||
|
logger.error('[SkillExecutor] Parallel skill promise rejected (uncaught)', {
|
||||||
|
skillId: currentItem.skillId,
|
||||||
|
taskId: context.taskId,
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
const now = new Date();
|
||||||
|
results.push({
|
||||||
|
skillId: currentItem.skillId,
|
||||||
|
skillName: currentItem.skillId,
|
||||||
|
status: 'error',
|
||||||
|
issues: [{
|
||||||
|
severity: 'ERROR',
|
||||||
|
type: SkillErrorCodes.SKILL_EXECUTION_ERROR,
|
||||||
|
message: `${currentItem.skillId} 执行异常: ${errorMessage}`,
|
||||||
|
}],
|
||||||
|
error: errorMessage,
|
||||||
|
executionTime: 0,
|
||||||
|
startedAt: now,
|
||||||
|
completedAt: now,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Promise 本身 rejected — 极端情况下的兜底
|
|
||||||
const errorMessage = outcome.reason instanceof Error
|
|
||||||
? outcome.reason.message
|
|
||||||
: String(outcome.reason);
|
|
||||||
logger.error('[SkillExecutor] Parallel skill promise rejected (uncaught)', {
|
|
||||||
skillId: stage[i].skillId,
|
|
||||||
taskId: context.taskId,
|
|
||||||
error: errorMessage,
|
|
||||||
});
|
|
||||||
const now = new Date();
|
|
||||||
results.push({
|
|
||||||
skillId: stage[i].skillId,
|
|
||||||
skillName: stage[i].skillId,
|
|
||||||
status: 'error',
|
|
||||||
issues: [{
|
|
||||||
severity: 'ERROR',
|
|
||||||
type: SkillErrorCodes.SKILL_EXECUTION_ERROR,
|
|
||||||
message: `${stage[i].skillId} 执行异常: ${errorMessage}`,
|
|
||||||
}],
|
|
||||||
error: errorMessage,
|
|
||||||
executionTime: 0,
|
|
||||||
startedAt: now,
|
|
||||||
completedAt: now,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const DEFAULT_PROFILE: JournalProfile = {
|
|||||||
tolerancePercent: 0.1,
|
tolerancePercent: 0.1,
|
||||||
},
|
},
|
||||||
timeout: 300000, // 5min: Python + LLM核查(内部180s超时降级) + 长文档余量
|
timeout: 300000, // 5min: Python + LLM核查(内部180s超时降级) + 长文档余量
|
||||||
|
parallelGroup: 'llm-review', // 与其余模块并行,缩短总时长
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
skillId: 'EditorialSkill',
|
skillId: 'EditorialSkill',
|
||||||
@@ -57,6 +58,7 @@ export const DEFAULT_PROFILE: JournalProfile = {
|
|||||||
strictness: 'STANDARD',
|
strictness: 'STANDARD',
|
||||||
continueOnError: true,
|
continueOnError: true,
|
||||||
timeoutMultiplier: 1.0,
|
timeoutMultiplier: 1.0,
|
||||||
|
maxConcurrency: 4, // 受控并行:默认同时跑4个模块
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
createPartialContextFromTask,
|
createPartialContextFromTask,
|
||||||
registerBuiltinSkills,
|
registerBuiltinSkills,
|
||||||
ExecutionSummary,
|
ExecutionSummary,
|
||||||
|
SkillResult,
|
||||||
} from '../skills/index.js';
|
} from '../skills/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -307,6 +308,7 @@ export async function registerReviewWorker() {
|
|||||||
},
|
},
|
||||||
forensicsResult: skillsSummary.results.find(r => r.skillId === 'DataForensicsSkill')?.data,
|
forensicsResult: skillsSummary.results.find(r => r.skillId === 'DataForensicsSkill')?.data,
|
||||||
clinicalReview: clinicalResult,
|
clinicalReview: clinicalResult,
|
||||||
|
skillProgress: buildSkillProgressMap(skillsSummary.results),
|
||||||
}
|
}
|
||||||
: null;
|
: 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 架构执行审查
|
* 使用 V2.0 Skills 架构执行审查
|
||||||
*/
|
*/
|
||||||
@@ -480,9 +495,73 @@ async function executeWithSkills(
|
|||||||
fileSize,
|
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
|
// 执行 Pipeline
|
||||||
const executor = new SkillExecutor();
|
const executor = new SkillExecutor({
|
||||||
|
onSkillComplete: async (_skillId, result) => {
|
||||||
|
await persistSkillResult(result);
|
||||||
|
},
|
||||||
|
});
|
||||||
const summary = await executor.execute(profile, partialContext);
|
const summary = await executor.execute(profile, partialContext);
|
||||||
|
await persistQueue;
|
||||||
|
|
||||||
// 输出执行结果
|
// 输出执行结果
|
||||||
console.log(`\n 📊 Skills 执行结果:`);
|
console.log(`\n 📊 Skills 执行结果:`);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# RVW稿件审查模块 - 当前状态与开发指南
|
# RVW稿件审查模块 - 当前状态与开发指南
|
||||||
|
|
||||||
> **文档版本:** v6.0
|
> **文档版本:** v6.1
|
||||||
> **创建日期:** 2026-01-07
|
> **创建日期:** 2026-01-07
|
||||||
> **最后更新:** 2026-03-07
|
> **最后更新:** 2026-03-10
|
||||||
> **维护者:** 开发团队
|
> **维护者:** 开发团队
|
||||||
> **当前状态:** 🚀 **V3.0 "智能审稿增强" 完成(LLM数据核查 + 临床评估 + 稳定性增强)**
|
> **当前状态:** 🚀 **V3.0.1 "性能与体验增强" 完成(4模块并行 + 增量展示 + 导出补全)**
|
||||||
> **文档目的:** 快速了解RVW模块状态,为新AI助手提供上下文
|
> **文档目的:** 快速了解RVW模块状态,为新AI助手提供上下文
|
||||||
>
|
>
|
||||||
> **🎉 V3.0 进展(2026-03-07):**
|
> **🎉 V3.0 进展(2026-03-07):**
|
||||||
@@ -14,6 +14,12 @@
|
|||||||
> - ✅ **稳定性增强**:SkillExecutor 使用 Promise.allSettled 实现并行故障隔离
|
> - ✅ **稳定性增强**:SkillExecutor 使用 Promise.allSettled 实现并行故障隔离
|
||||||
> - ✅ **部分完成支持**:新增 `partial_completed` 状态 + `errorDetails` 字段,部分模块失败仍展示成功结果
|
> - ✅ **部分完成支持**:新增 `partial_completed` 状态 + `errorDetails` 字段,部分模块失败仍展示成功结果
|
||||||
> - ✅ **前端 4 Tab 报告**:稿约规范性 / 方法学 / 数据验证 / 临床评估,Word 导出全覆盖
|
> - ✅ **前端 4 Tab 报告**:稿约规范性 / 方法学 / 数据验证 / 临床评估,Word 导出全覆盖
|
||||||
|
>
|
||||||
|
> **⚡ V3.0.1 增强(2026-03-10):**
|
||||||
|
> - ✅ **4模块受控并行**:DataForensics 与 Editorial/Methodology/Clinical 同组并行,`maxConcurrency=4` 控制并发上限
|
||||||
|
> - ✅ **增量结果持久化**:每个 Skill 完成即写入任务中间结果,`getTaskDetail` 返回模块级 `reviewProgress`
|
||||||
|
> - ✅ **先出先看**:TaskDetail 在审查中即可展示已完成模块(无需等待全流程结束)
|
||||||
|
> - ✅ **Word 导出修复**:补齐“数据验证”章节,导出汇总 + 表格明细 + 该表问题列表
|
||||||
>
|
>
|
||||||
> **V2.0 进展回顾:**
|
> **V2.0 进展回顾:**
|
||||||
> - ✅ L1 算术验证 + L2 统计验证 + L2.5 一致性取证
|
> - ✅ L1 算术验证 + L2 统计验证 + L2.5 一致性取证
|
||||||
@@ -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 临床专业评估服务
|
│ ├── clinicalService.ts # 🆕 V3.0 临床专业评估服务
|
||||||
│ └── utils.ts # 工具函数
|
│ └── utils.ts # 工具函数
|
||||||
├── workers/
|
├── 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 架构
|
├── skills/ # V2.0 Skills 架构
|
||||||
│ ├── core/ # 核心框架(types, registry, executor[allSettled]等)
|
│ ├── core/ # 核心框架(types, registry, executor[allSettled]等)
|
||||||
│ ├── library/ # Skill 实现(Forensics[+LLM], Editorial, Methodology, 🆕Clinical)
|
│ ├── library/ # Skill 实现(Forensics[+LLM], Editorial, Methodology, 🆕Clinical)
|
||||||
@@ -125,7 +131,7 @@ frontend-v2/src/modules/rvw/
|
|||||||
├── Header.tsx # 页头(上传按钮)
|
├── Header.tsx # 页头(上传按钮)
|
||||||
├── Sidebar.tsx # 侧边栏导航
|
├── Sidebar.tsx # 侧边栏导航
|
||||||
├── TaskTable.tsx # 任务列表表格(支持 partial_completed 状态)
|
├── TaskTable.tsx # 任务列表表格(支持 partial_completed 状态)
|
||||||
├── TaskDetail.tsx # 任务详情(进度条+报告+Word导出+部分完成警告)
|
├── TaskDetail.tsx # 任务详情(进度条+增量展示+Word导出+部分完成警告)
|
||||||
├── EditorialReport.tsx # 稿约规范性报告
|
├── EditorialReport.tsx # 稿约规范性报告
|
||||||
├── MethodologyReport.tsx # 方法学评估报告
|
├── MethodologyReport.tsx # 方法学评估报告
|
||||||
├── ForensicsReport.tsx # 数据验证报告(含 LLM 核查结果)
|
├── ForensicsReport.tsx # 数据验证报告(含 LLM 核查结果)
|
||||||
@@ -446,6 +452,17 @@ Content-Type: multipart/form-data
|
|||||||
| 前端 partial_completed UI | ✅ 已完成 | 琥珀色警告横幅 + 列表"部分完成"标签 |
|
| 前端 partial_completed UI | ✅ 已完成 | 琥珀色警告横幅 + 列表"部分完成"标签 |
|
||||||
| Word 导出覆盖临床评估 | ✅ 已完成 | 导出报告包含临床专业评估章节 |
|
| Word 导出覆盖临床评估 | ✅ 已完成 | 导出报告包含临床专业评估章节 |
|
||||||
|
|
||||||
|
### ⚡ V3.0.1 "性能与体验增强" 开发进度(2026-03-10)
|
||||||
|
|
||||||
|
| 任务 | 状态 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| DataForensics 并入并行组 | ✅ 已完成 | 与 Editorial/Methodology/Clinical 同组并行执行 |
|
||||||
|
| 并发上限控制 | ✅ 已完成 | Executor 支持按 `maxConcurrency` 分批并行 |
|
||||||
|
| 模块完成即持久化 | ✅ 已完成 | Worker 通过 `onSkillComplete` 增量写入 `contextData` |
|
||||||
|
| 任务详情增量返回 | ✅ 已完成 | `getTaskDetail` 返回模块结果与 `reviewProgress` |
|
||||||
|
| 前端先出先看 | ✅ 已完成 | 审查过程中实时展示已完成 Tab |
|
||||||
|
| Word 导出补齐数据验证 | ✅ 已完成 | 导出包含数据验证汇总、表格明细、该表问题列表 |
|
||||||
|
|
||||||
### 后续版本(V3.1+)
|
### 后续版本(V3.1+)
|
||||||
|
|
||||||
- [ ] 全面移除评分机制(只列问题,不打分)
|
- [ ] 全面移除评分机制(只列问题,不打分)
|
||||||
@@ -462,7 +479,7 @@ Content-Type: multipart/form-data
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**文档版本:** v6.0
|
**文档版本:** v6.1
|
||||||
**最后更新:** 2026-03-07
|
**最后更新:** 2026-03-10
|
||||||
**当前状态:** 🚀 V3.0 "智能审稿增强" 完成(LLM数据核查 + 临床评估 + 稳定性增强)
|
**当前状态:** 🚀 V3.0.1 "性能与体验增强" 完成(4模块并行 + 增量展示 + 导出补全)
|
||||||
**下一步:** V3.1 移除评分机制 + 单模块重试
|
**下一步:** V3.1 移除评分机制 + 单模块重试
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const getProgressSteps = (selectedAgents: string[]) => {
|
|||||||
const steps = [
|
const steps = [
|
||||||
{ key: 'upload', label: '上传文档' },
|
{ key: 'upload', label: '上传文档' },
|
||||||
{ key: 'extract', label: '文本提取' },
|
{ key: 'extract', label: '文本提取' },
|
||||||
|
{ key: 'forensics', label: '数据验证' },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (selectedAgents.includes('editorial')) {
|
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 isCompleted = task.status === 'completed' || task.status === 'partial_completed';
|
||||||
const isPartial = task.status === 'partial_completed';
|
const isPartial = task.status === 'partial_completed';
|
||||||
const isFailed = task.status === 'failed';
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -110,20 +122,20 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
|||||||
}
|
}
|
||||||
}, [isCompleted, task.id, report]);
|
}, [isCompleted, task.id, report]);
|
||||||
|
|
||||||
// 报告加载后自动设置正确的 Tab
|
// 报告(含增量结果)加载后自动设置可用 Tab
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (report) {
|
if (displayReport) {
|
||||||
if (report.editorialReview) {
|
if (displayReport.editorialReview) {
|
||||||
setActiveTab('editorial');
|
setActiveTab('editorial');
|
||||||
} else if (report.methodologyReview) {
|
} else if (displayReport.methodologyReview) {
|
||||||
setActiveTab('methodology');
|
setActiveTab('methodology');
|
||||||
} else if (report.forensicsResult) {
|
} else if (displayReport.forensicsResult) {
|
||||||
setActiveTab('forensics');
|
setActiveTab('forensics');
|
||||||
} else if (report.clinicalReview) {
|
} else if (displayReport.clinicalReview) {
|
||||||
setActiveTab('clinical');
|
setActiveTab('clinical');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [report]);
|
}, [displayReport]);
|
||||||
|
|
||||||
// 动态获取进度步骤
|
// 动态获取进度步骤
|
||||||
const progressSteps = getProgressSteps(task.selectedAgents || ['editorial', 'methodology']);
|
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 getStepStatus = (stepKey: string): 'completed' | 'active' | 'pending' => {
|
||||||
const hasEditorial = task.selectedAgents?.includes('editorial');
|
const hasEditorial = task.selectedAgents?.includes('editorial');
|
||||||
const hasMethodology = task.selectedAgents?.includes('methodology');
|
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') {
|
if (task.status === 'pending') {
|
||||||
return stepKey === 'upload' ? 'completed' : '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 (task.status === 'reviewing' || task.status === 'reviewing_editorial') {
|
||||||
if (['upload', 'extract'].includes(stepKey)) return 'completed';
|
if (['upload', 'extract'].includes(stepKey)) return 'completed';
|
||||||
|
if (stepKey === 'forensics') return 'active';
|
||||||
if (stepKey === 'editorial' && hasEditorial) return 'active';
|
if (stepKey === 'editorial' && hasEditorial) return 'active';
|
||||||
|
if (stepKey === 'methodology' && hasMethodology) return 'active';
|
||||||
|
if (stepKey === 'clinical' && hasClinical) return 'active';
|
||||||
return 'pending';
|
return 'pending';
|
||||||
}
|
}
|
||||||
if (task.status === 'reviewing_methodology') {
|
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 } }));
|
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) {
|
if (report.editorialReview) {
|
||||||
children.push(
|
children.push(
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
text: '一、稿约规范性评估',
|
text: nextSectionTitle('稿约规范性评估'),
|
||||||
heading: HeadingLevel.HEADING_1,
|
heading: HeadingLevel.HEADING_1,
|
||||||
spacing: { before: 400, after: 200 },
|
spacing: { before: 400, after: 200 },
|
||||||
})
|
})
|
||||||
@@ -313,7 +351,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
|||||||
if (report.methodologyReview) {
|
if (report.methodologyReview) {
|
||||||
children.push(
|
children.push(
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
text: '二、方法学评估',
|
text: nextSectionTitle('方法学评估'),
|
||||||
heading: HeadingLevel.HEADING_1,
|
heading: HeadingLevel.HEADING_1,
|
||||||
spacing: { before: 400, after: 200 },
|
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) {
|
if (report.clinicalReview) {
|
||||||
const sectionNum = report.methodologyReview ? '三' : '二';
|
|
||||||
children.push(
|
children.push(
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
text: `${sectionNum}、临床专业评估`,
|
text: nextSectionTitle('临床专业评估'),
|
||||||
heading: HeadingLevel.HEADING_1,
|
heading: HeadingLevel.HEADING_1,
|
||||||
spacing: { before: 400, after: 200 },
|
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">
|
<div className="text-center text-slate-500 text-sm bg-slate-50 rounded-lg py-4">
|
||||||
<p>AI 正在分析您的稿件,这可能需要 1-3 分钟</p>
|
<p>AI 正在分析您的稿件,这可能需要 1-3 分钟</p>
|
||||||
<p className="text-slate-400 mt-1">请耐心等待,完成后将自动显示结果</p>
|
<p className="text-slate-400 mt-1">模块完成后会实时显示,无需等待全部结束</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -594,8 +771,8 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
|||||||
</div>
|
</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">
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
<CheckCircle className="w-6 h-6 text-white/90" />
|
<CheckCircle className="w-6 h-6 text-white/90" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">审查完成</h2>
|
<h2 className="text-lg font-semibold">{isCompleted ? '审查完成' : '已完成模块结果(实时更新)'}</h2>
|
||||||
<p className="text-indigo-100 text-sm">
|
<p className="text-indigo-100 text-sm">
|
||||||
审查用时 {report.durationSeconds ? formatTime(report.durationSeconds) : '-'}
|
审查用时 {displayReport.durationSeconds ? formatTime(displayReport.durationSeconds) : '-'}
|
||||||
{report.completedAt && ` · ${new Date(report.completedAt).toLocaleString('zh-CN')}`}
|
{displayReport.completedAt && ` · ${new Date(displayReport.completedAt).toLocaleString('zh-CN')}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -615,7 +792,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
|||||||
|
|
||||||
{/* Tab切换 */}
|
{/* Tab切换 */}
|
||||||
<div className="flex gap-1 bg-slate-200/50 p-1 rounded-lg mb-6 w-fit mx-auto">
|
<div className="flex gap-1 bg-slate-200/50 p-1 rounded-lg mb-6 w-fit mx-auto">
|
||||||
{report.editorialReview && (
|
{displayReport.editorialReview && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('editorial')}
|
onClick={() => setActiveTab('editorial')}
|
||||||
className={`px-6 py-2 rounded-md text-sm transition-all ${
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
{report.methodologyReview && (
|
{displayReport.methodologyReview && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('methodology')}
|
onClick={() => setActiveTab('methodology')}
|
||||||
className={`px-6 py-2 rounded-md text-sm transition-all ${
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
{report.forensicsResult && (
|
{displayReport.forensicsResult && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('forensics')}
|
onClick={() => setActiveTab('forensics')}
|
||||||
className={`px-6 py-2 rounded-md text-sm transition-all ${
|
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'
|
: 'font-medium text-slate-500 hover:text-slate-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
数据验证 ({report.forensicsResult.summary.totalIssues || 0}个问题)
|
数据验证 ({displayReport.forensicsResult.summary.totalIssues || 0}个问题)
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{report.clinicalReview && (
|
{displayReport.clinicalReview && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('clinical')}
|
onClick={() => setActiveTab('clinical')}
|
||||||
className={`px-6 py-2 rounded-md text-sm transition-all ${
|
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>
|
</div>
|
||||||
|
|
||||||
{/* 非 docx 文件无数据验证提示 */}
|
{/* 非 docx 文件无数据验证提示 */}
|
||||||
{!report.forensicsResult && (report.editorialReview || report.methodologyReview) && (() => {
|
{!displayReport.forensicsResult && (displayReport.editorialReview || displayReport.methodologyReview) && (() => {
|
||||||
const fileName = task.fileName || '';
|
const fileName = task.fileName || '';
|
||||||
const isPdf = fileName.toLowerCase().endsWith('.pdf');
|
const isPdf = fileName.toLowerCase().endsWith('.pdf');
|
||||||
const isDoc = fileName.toLowerCase().endsWith('.doc');
|
const isDoc = fileName.toLowerCase().endsWith('.doc');
|
||||||
@@ -686,17 +863,17 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* 报告内容 */}
|
{/* 报告内容 */}
|
||||||
{activeTab === 'editorial' && report.editorialReview && (
|
{activeTab === 'editorial' && displayReport.editorialReview && (
|
||||||
<EditorialReport data={report.editorialReview} />
|
<EditorialReport data={displayReport.editorialReview} />
|
||||||
)}
|
)}
|
||||||
{activeTab === 'methodology' && report.methodologyReview && (
|
{activeTab === 'methodology' && displayReport.methodologyReview && (
|
||||||
<MethodologyReport data={report.methodologyReview} />
|
<MethodologyReport data={displayReport.methodologyReview} />
|
||||||
)}
|
)}
|
||||||
{activeTab === 'forensics' && report.forensicsResult && (
|
{activeTab === 'forensics' && displayReport.forensicsResult && (
|
||||||
<ForensicsReport data={report.forensicsResult} />
|
<ForensicsReport data={displayReport.forensicsResult} />
|
||||||
)}
|
)}
|
||||||
{activeTab === 'clinical' && report.clinicalReview && (
|
{activeTab === 'clinical' && displayReport.clinicalReview && (
|
||||||
<ClinicalReport data={report.clinicalReview} />
|
<ClinicalReport data={displayReport.clinicalReview} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ export interface ReviewTask {
|
|||||||
timeoutCount: number;
|
timeoutCount: number;
|
||||||
totalSkills: 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;
|
createdAt: string;
|
||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
durationSeconds?: number;
|
durationSeconds?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user