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 });
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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个模块
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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 执行结果:`);
|
||||
|
||||
@@ -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 移除评分机制 + 单模块重试
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user