feat(rvw,asl): RVW V3.0 smart review + ASL deep research history + stability

RVW module (V3.0 Smart Review Enhancement):
- Add LLM data validation via PromptService (RVW_DATA_VALIDATION)
- Add ClinicalAssessmentSkill with FINER-based evaluation (RVW_CLINICAL)
- Remove all numeric scores from UI (editorial, methodology, overall)
- Implement partial_completed status with Promise.allSettled
- Add error_details JSON field to ReviewTask for granular failure info
- Fix overallStatus logic: warning status now counts as success
- Restructure ForensicsReport: per-table LLM results, remove top-level block
- Refactor ClinicalReport: structured collapsible sections
- Increase all skill timeouts to 300s for long manuscripts (20+ pages)
- Increase DataForensics LLM timeout to 180s, pg-boss to 15min
- Executor default fallback timeout 30s -> 60s

ASL module:
- Add deep research history with sidebar accordion UI
- Implement waterfall flow for historical task display
- Upgrade Unifuncs DeepSearch API from S2 to S3 with fallback
- Add ASL_SR module seed for admin configurability
- Fix new search button inconsistency

Docs:
- Update RVW module status to V3.0
- Update deployment changelist
- Add 0305 deployment summary

DB Migration:
- Add error_details JSONB column to rvw_schema.review_tasks

Tested: All 4 review modules verified, partial completion working
Made-with: Cursor
This commit is contained in:
2026-03-07 19:24:21 +08:00
parent 91ae80888e
commit 87655ea7e6
46 changed files with 2929 additions and 511 deletions

View File

@@ -0,0 +1,3 @@
-- AlterTable: ReviewTask 新增 error_details 字段
-- 用于存储结构化的错误详情(各 Skill 成功/失败状态),支持 partial_completed 场景
ALTER TABLE "rvw_schema"."review_tasks" ADD COLUMN "error_details" JSONB;

View File

@@ -285,6 +285,7 @@ model ReviewTask {
methodologyStatus String? @map("methodology_status")
picoExtract Json? @map("pico_extract")
contextData Json? @map("context_data") /// Skills V2.0 执行上下文数据
errorDetails Json? @map("error_details") /// 结构化错误详情(记录各 Skill 成功/失败状态)
isArchived Boolean @default(false) @map("is_archived")
archivedAt DateTime? @map("archived_at")
modelUsed String? @map("model_used")

View File

@@ -34,7 +34,7 @@ function extractVariables(content: string): string[] {
return Array.from(variables);
}
// RVW Prompt 配置(只有 2 个)
// RVW Prompt 配置
// 注意topic_evaluation_* 是"选题评估"功能,不属于 RVW 审稿模块
const rvwPrompts = [
{
@@ -53,6 +53,55 @@ const rvwPrompts = [
file: 'review_methodology_system.txt',
modelConfig: { model: 'deepseek-v3', temperature: 0.3 },
},
{
code: 'RVW_DATA_VALIDATION',
name: '数据验证 LLM 核查',
module: 'RVW',
description: '使用大语言模型对医学科研稿件中的表格进行核查,包括百分比计算、统计检验方法、统计分析结果准确性等。',
inlineContent: `你正在处理的是医学科研稿件请对附件中的表格进行核查包括百分比计算是准确统计检验方法使用是否正确统计分析检验结果是否准确卡方检验中如果适用fisher精确检验的条件不给卡方值不是问题请忽略。最终形成一个核查报告重点列出核查出的问题。
请按表格逐个输出核查结果,使用以下格式:
## 表N: <表格标题>
<该表格的核查结论和发现的问题>`,
modelConfig: { model: 'deepseek-v3', temperature: 0.3 },
},
{
code: 'RVW_CLINICAL',
name: '临床专业评估',
module: 'RVW',
description: '基于 FINER 标准(可行性、创新性、伦理性、相关性)对研究选题进行系统评估,涵盖创新性、临床价值、科学性、可行性四个维度。',
inlineContent: `你作为临床研究设计智能顾问CRD-IA将依据 FINER 标准(可行性 Feasibility、创新性 Interesting、伦理性 Ethical、相关性 Relevant对研究选题进行系统评估并用中文回答。
第一步:研究问题的明确性评估
1. 判断研究问题是否清晰
研究问题是否包含完整的 PICO 要素Population/Intervention/Comparator/Outcome
若 PICO 不完整,提示研究者补充必要信息。
2. 研究问题的完善与优化
研究者已有明确的临床问题:通过对话识别其陈述中的关键信息,优化 PICO 框架。
研究者尚未形成清晰的研究问题:询问其关注的疾病领域,并协助提出可供研究的具体问题。
判断是否需进一步咨询专家:若研究问题仍不够明确,建议研究者寻求该领域专家的意见。
第二步:研究问题的要素完整性验证
CRD-IA 将按以下维度评估研究问题的完整性,确保其符合 FINER 标准,并依据 循证医学原则 和 ICH-GCP 规范 进行多维度价值评估。评估逻辑包括 假设解构 → 知识验证 → 缺陷识别 → 优化建议,所有结论需明确 证据等级A/B/C类
1. 创新性评估
检索国际指南、PubMed 已发表论文,以及 ICTRP、ClinicalTrials.gov 近三年注册研究,分析研究选题的 相似度(相似度<30%为高创新)。
识别研究假设中的 知识突破点,判断是否填补现有研究空白。
2. 临床价值评估
通过 PubMed 检索该疾病的 疾病负担指数(参考最新 GBD 数据),判断该研究的 临床紧迫性。
检索该疾病相关的 国际指南,明确指南是否指出该问题 需要进一步证据。
评估研究者定义的 结局指标 是否与临床关注的核心获益一致;如偏离临床重点,应予以提示。
3. 科学性评估
研究假设是否 符合基本科学原理,若存在与已知科学常识矛盾的部分,应提示研究者重新审视理论基础。
该研究问题能否通过 合理的研究设计 进行科学验证。
4. 可行性评估
进行 风险-受益比分析(基于 DECISION 模型),评估该研究是否存在 重大伦理风险,影响可行性。
估算目标患者群体的 潜在样本量,若可能难以收集足够样本,应明确指出并建议调整研究方案。
第三步:最终评估结论与优化建议
在综合分析 创新性、临床价值、科学性、可行性 之后CRD-IA 将:
总结研究选题的整体评估结果,标明各项评估的 证据等级A/B/C类
提出优化建议,帮助研究者改进研究设计,使其更具科学价值、临床意义和可操作性。
回答需要考虑聊天历史。
如果过程中有不明确的问题,通过聊天让用户补充相关信息。除特殊要求外,用中文回复。`,
modelConfig: { model: 'deepseek-v3', temperature: 0.3 },
},
];
async function main() {
@@ -63,14 +112,20 @@ async function main() {
for (const prompt of rvwPrompts) {
console.log(`📄 处理: ${prompt.code} (${prompt.name})`);
// 读取文件内容
const filePath = path.join(promptsDir, prompt.file);
if (!fs.existsSync(filePath)) {
console.log(` ⚠️ 文件不存在: ${filePath}`);
continue;
// 读取内容:优先使用 inlineContent否则从文件读取
let content: string;
if ((prompt as any).inlineContent) {
content = (prompt as any).inlineContent;
console.log(` 📝 使用内联内容,长度: ${content.length} 字符`);
} else {
const filePath = path.join(promptsDir, (prompt as any).file);
if (!fs.existsSync(filePath)) {
console.log(` ⚠️ 文件不存在: ${filePath}`);
continue;
}
content = fs.readFileSync(filePath, 'utf-8').trim();
}
const content = fs.readFileSync(filePath, 'utf-8').trim();
const variables = extractVariables(content);
console.log(` 📝 内容长度: ${content.length} 字符`);

View File

@@ -89,6 +89,14 @@ const MODULES = [
is_active: true,
sort_order: 100,
},
{
code: 'ASL_SR',
name: '系统综述项目',
description: 'AI智能文献模块内的系统综述全流程功能初筛/复筛/提取/图表/Meta可按用户/租户独立配置开关',
icon: 'FolderOutlined',
is_active: true,
sort_order: 101,
},
];
async function main() {

View File

@@ -52,6 +52,49 @@ const RVW_FALLBACKS: Record<string, FallbackPrompt> = {
请输出JSON格式的评估结果包含overall_score和parts数组。`,
modelConfig: { model: 'deepseek-v3', temperature: 0.3 },
},
RVW_DATA_VALIDATION: {
content: `你正在处理的是医学科研稿件请对附件中的表格进行核查包括百分比计算是准确统计检验方法使用是否正确统计分析检验结果是否准确卡方检验中如果适用fisher精确检验的条件不给卡方值不是问题请忽略。最终形成一个核查报告重点列出核查出的问题。
请按表格逐个输出核查结果,使用以下格式:
## 表N: <表格标题>
<该表格的核查结论和发现的问题>`,
modelConfig: { model: 'deepseek-v3', temperature: 0.3 },
},
RVW_CLINICAL: {
content: `你作为临床研究设计智能顾问CRD-IA将依据 FINER 标准(可行性 Feasibility、创新性 Interesting、伦理性 Ethical、相关性 Relevant对研究选题进行系统评估并用中文回答。
第一步:研究问题的明确性评估
1. 判断研究问题是否清晰
研究问题是否包含完整的 PICO 要素Population/Intervention/Comparator/Outcome
若 PICO 不完整,提示研究者补充必要信息。
2. 研究问题的完善与优化
研究者已有明确的临床问题:通过对话识别其陈述中的关键信息,优化 PICO 框架。
研究者尚未形成清晰的研究问题:询问其关注的疾病领域,并协助提出可供研究的具体问题。
判断是否需进一步咨询专家:若研究问题仍不够明确,建议研究者寻求该领域专家的意见。
第二步:研究问题的要素完整性验证
CRD-IA 将按以下维度评估研究问题的完整性,确保其符合 FINER 标准,并依据 循证医学原则 和 ICH-GCP 规范 进行多维度价值评估。评估逻辑包括 假设解构 → 知识验证 → 缺陷识别 → 优化建议,所有结论需明确 证据等级A/B/C类
1. 创新性评估
检索国际指南、PubMed 已发表论文,以及 ICTRP、ClinicalTrials.gov 近三年注册研究,分析研究选题的 相似度(相似度<30%为高创新)。
识别研究假设中的 知识突破点,判断是否填补现有研究空白。
2. 临床价值评估
通过 PubMed 检索该疾病的 疾病负担指数(参考最新 GBD 数据),判断该研究的 临床紧迫性。
检索该疾病相关的 国际指南,明确指南是否指出该问题 需要进一步证据。
评估研究者定义的 结局指标 是否与临床关注的核心获益一致;如偏离临床重点,应予以提示。
3. 科学性评估
研究假设是否 符合基本科学原理,若存在与已知科学常识矛盾的部分,应提示研究者重新审视理论基础。
该研究问题能否通过 合理的研究设计 进行科学验证。
4. 可行性评估
进行 风险-受益比分析(基于 DECISION 模型),评估该研究是否存在 重大伦理风险,影响可行性。
估算目标患者群体的 潜在样本量,若可能难以收集足够样本,应明确指出并建议调整研究方案。
第三步:最终评估结论与优化建议
在综合分析 创新性、临床价值、科学性、可行性 之后CRD-IA 将:
总结研究选题的整体评估结果,标明各项评估的 证据等级A/B/C类
提出优化建议,帮助研究者改进研究设计,使其更具科学价值、临床意义和可操作性。
回答需要考虑聊天历史。
如果过程中有不明确的问题,通过聊天让用户补充相关信息。除特殊要求外,用中文回复。`,
modelConfig: { model: 'deepseek-v3', temperature: 0.3 },
},
};
/**

View File

@@ -5,6 +5,7 @@ import { authenticate, requireModule } from '../../../../common/auth/auth.middle
export async function chartingRoutes(fastify: FastifyInstance) {
fastify.addHook('onRequest', authenticate);
fastify.addHook('onRequest', requireModule('ASL'));
fastify.addHook('onRequest', requireModule('ASL_SR'));
fastify.get('/prisma-data/:projectId', chartingController.getPrismaData);
fastify.get('/baseline-data/:projectId', chartingController.getBaselineData);

View File

@@ -131,6 +131,82 @@ export async function executeTask(
}
}
// ─── GET /research/v2/tasks ─────────────────────
export async function listTasks(
request: FastifyRequest,
reply: FastifyReply
) {
try {
const userId = request.user?.userId;
if (!userId) {
return reply.code(401).send({ success: false, error: '用户未认证' });
}
const tasks = await prisma.aslResearchTask.findMany({
where: {
userId,
status: { not: 'draft' },
},
orderBy: { createdAt: 'desc' },
take: 100,
select: {
id: true,
query: true,
status: true,
resultCount: true,
createdAt: true,
completedAt: true,
},
});
return reply.send({ success: true, data: tasks });
} catch (error: any) {
logger.error('[DeepResearchController] listTasks failed', {
error: error.message,
});
return reply.code(500).send({ success: false, error: error.message });
}
}
// ─── DELETE /research/tasks/:taskId ─────────────
export async function deleteTask(
request: FastifyRequest<{ Params: TaskParams }>,
reply: FastifyReply
) {
try {
const userId = request.user?.userId;
if (!userId) {
return reply.code(401).send({ success: false, error: '用户未认证' });
}
const { taskId } = request.params;
const task = await prisma.aslResearchTask.findUnique({
where: { id: taskId },
select: { userId: true },
});
if (!task) {
return reply.code(404).send({ success: false, error: '任务不存在' });
}
if (task.userId !== userId) {
return reply.code(403).send({ success: false, error: '无权删除此任务' });
}
await prisma.aslResearchTask.delete({ where: { id: taskId } });
return reply.send({ success: true });
} catch (error: any) {
logger.error('[DeepResearchController] deleteTask failed', {
error: error.message,
});
return reply.code(500).send({ success: false, error: error.message });
}
}
// ─── GET /research/tasks/:taskId ────────────────
export async function getTask(
@@ -138,6 +214,11 @@ export async function getTask(
reply: FastifyReply
) {
try {
const userId = request.user?.userId;
if (!userId) {
return reply.code(401).send({ success: false, error: '用户未认证' });
}
const { taskId } = request.params;
const task = await prisma.aslResearchTask.findUnique({
@@ -148,6 +229,10 @@ export async function getTask(
return reply.code(404).send({ success: false, error: '任务不存在' });
}
if (task.userId !== userId) {
return reply.code(403).send({ success: false, error: '无权查看此任务' });
}
return reply.send({
success: true,
data: {

View File

@@ -17,6 +17,7 @@ export async function extractionRoutes(fastify: FastifyInstance) {
fastify.register(async function authedRoutes(sub) {
sub.addHook('onRequest', authenticate);
sub.addHook('onRequest', requireModule('ASL'));
sub.addHook('onRequest', requireModule('ASL_SR'));
// ── 模板 API ──────────────────────────────
sub.get('/templates', ctrl.listTemplates);

View File

@@ -9,6 +9,7 @@ import { authenticate, requireModule } from '../../../../common/auth/auth.middle
export async function metaAnalysisRoutes(fastify: FastifyInstance) {
fastify.addHook('preHandler', authenticate);
fastify.addHook('preHandler', requireModule('ASL'));
fastify.addHook('preHandler', requireModule('ASL_SR'));
fastify.post('/run', ctrl.runAnalysis);
fastify.get('/project-data/:projectId', ctrl.getProjectData);

View File

@@ -15,73 +15,39 @@ import { metaAnalysisRoutes } from '../meta-analysis/routes/index.js';
import { authenticate, requireModule } from '../../../common/auth/auth.middleware.js';
export async function aslRoutes(fastify: FastifyInstance) {
// ==================== 筛选项目路由 ====================
// 创建筛选项目
fastify.post('/projects', { preHandler: [authenticate, requireModule('ASL')] }, projectController.createProject);
// 获取用户的所有项目
fastify.get('/projects', { preHandler: [authenticate, requireModule('ASL')] }, projectController.getProjects);
// 获取单个项目详情
fastify.get('/projects/:projectId', { preHandler: [authenticate, requireModule('ASL')] }, projectController.getProjectById);
// 更新项目
fastify.put('/projects/:projectId', { preHandler: [authenticate, requireModule('ASL')] }, projectController.updateProject);
// 删除项目
fastify.delete('/projects/:projectId', { preHandler: [authenticate, requireModule('ASL')] }, projectController.deleteProject);
// SR 子模块公共 preHandler
const srAuth = [authenticate, requireModule('ASL'), requireModule('ASL_SR')];
// ==================== 文献管理路由 ====================
// ==================== 筛选项目路由 (需要 ASL_SR) ====================
// 导入文献JSON
fastify.post('/literatures/import', { preHandler: [authenticate, requireModule('ASL')] }, literatureController.importLiteratures);
// 导入文献Excel上传
fastify.post('/literatures/import-excel', { preHandler: [authenticate, requireModule('ASL')] }, literatureController.importLiteraturesFromExcel);
// 获取项目的文献列表
fastify.get('/projects/:projectId/literatures', { preHandler: [authenticate, requireModule('ASL')] }, literatureController.getLiteratures);
// 删除文献
fastify.delete('/literatures/:literatureId', { preHandler: [authenticate, requireModule('ASL')] }, literatureController.deleteLiterature);
fastify.post('/projects', { preHandler: srAuth }, projectController.createProject);
fastify.get('/projects', { preHandler: srAuth }, projectController.getProjects);
fastify.get('/projects/:projectId', { preHandler: srAuth }, projectController.getProjectById);
fastify.put('/projects/:projectId', { preHandler: srAuth }, projectController.updateProject);
fastify.delete('/projects/:projectId', { preHandler: srAuth }, projectController.deleteProject);
// ==================== 筛选任务路由 ====================
// ==================== 文献管理路由 (需要 ASL_SR) ====================
// 获取筛选任务进度
fastify.get('/projects/:projectId/screening-task', { preHandler: [authenticate, requireModule('ASL')] }, screeningController.getScreeningTask);
// 获取筛选结果列表(分页)
fastify.get('/projects/:projectId/screening-results', { preHandler: [authenticate, requireModule('ASL')] }, screeningController.getScreeningResults);
// 获取单个筛选结果详情
fastify.get('/screening-results/:resultId', { preHandler: [authenticate, requireModule('ASL')] }, screeningController.getScreeningResultDetail);
// 提交人工复核
fastify.post('/screening-results/:resultId/review', { preHandler: [authenticate, requireModule('ASL')] }, screeningController.reviewScreeningResult);
// ⭐ 获取项目统计数据Week 4 新增)
fastify.get('/projects/:projectId/statistics', { preHandler: [authenticate, requireModule('ASL')] }, screeningController.getProjectStatistics);
// TODO: 启动筛选任务Week 2 Day 2 已实现为同步流程,异步版本待实现)
// fastify.post('/projects/:projectId/screening/start', screeningController.startScreening);
fastify.post('/literatures/import', { preHandler: srAuth }, literatureController.importLiteratures);
fastify.post('/literatures/import-excel', { preHandler: srAuth }, literatureController.importLiteraturesFromExcel);
fastify.get('/projects/:projectId/literatures', { preHandler: srAuth }, literatureController.getLiteratures);
fastify.delete('/literatures/:literatureId', { preHandler: srAuth }, literatureController.deleteLiterature);
// ==================== 全文复筛路由 (Day 5 新增) ====================
// ==================== 筛选任务路由 (需要 ASL_SR) ====================
// 创建全文复筛任务
fastify.post('/fulltext-screening/tasks', { preHandler: [authenticate, requireModule('ASL')] }, fulltextScreeningController.createTask);
fastify.get('/projects/:projectId/screening-task', { preHandler: srAuth }, screeningController.getScreeningTask);
fastify.get('/projects/:projectId/screening-results', { preHandler: srAuth }, screeningController.getScreeningResults);
fastify.get('/screening-results/:resultId', { preHandler: srAuth }, screeningController.getScreeningResultDetail);
fastify.post('/screening-results/:resultId/review', { preHandler: srAuth }, screeningController.reviewScreeningResult);
fastify.get('/projects/:projectId/statistics', { preHandler: srAuth }, screeningController.getProjectStatistics);
// ==================== 全文复筛路由 (需要 ASL_SR) ====================
// 获取任务进度
fastify.get('/fulltext-screening/tasks/:taskId', { preHandler: [authenticate, requireModule('ASL')] }, fulltextScreeningController.getTaskProgress);
// 获取任务结果(支持筛选和分页)
fastify.get('/fulltext-screening/tasks/:taskId/results', { preHandler: [authenticate, requireModule('ASL')] }, fulltextScreeningController.getTaskResults);
// 人工审核决策
fastify.put('/fulltext-screening/results/:resultId/decision', { preHandler: [authenticate, requireModule('ASL')] }, fulltextScreeningController.updateDecision);
// 导出Excel
fastify.get('/fulltext-screening/tasks/:taskId/export', { preHandler: [authenticate, requireModule('ASL')] }, fulltextScreeningController.exportExcel);
fastify.post('/fulltext-screening/tasks', { preHandler: srAuth }, fulltextScreeningController.createTask);
fastify.get('/fulltext-screening/tasks/:taskId', { preHandler: srAuth }, fulltextScreeningController.getTaskProgress);
fastify.get('/fulltext-screening/tasks/:taskId/results', { preHandler: srAuth }, fulltextScreeningController.getTaskResults);
fastify.put('/fulltext-screening/results/:resultId/decision', { preHandler: srAuth }, fulltextScreeningController.updateDecision);
fastify.get('/fulltext-screening/tasks/:taskId/export', { preHandler: srAuth }, fulltextScreeningController.exportExcel);
// ==================== 智能文献检索路由 (DeepSearch V1.x — 保留兼容) ====================
@@ -105,6 +71,12 @@ export async function aslRoutes(fastify: FastifyInstance) {
// 启动异步执行
fastify.put('/research/tasks/:taskId/execute', { preHandler: [authenticate, requireModule('ASL')] }, deepResearchController.executeTask);
// V2.0 任务历史列表(最近 100 条)
fastify.get('/research/v2/tasks', { preHandler: [authenticate, requireModule('ASL')] }, deepResearchController.listTasks);
// V2.0 删除任务
fastify.delete('/research/tasks/:taskId', { preHandler: [authenticate, requireModule('ASL')] }, deepResearchController.deleteTask);
// V2.0 任务详情(状态 + 日志 + 结果)
fastify.get('/research/tasks/:taskId', { preHandler: [authenticate, requireModule('ASL')] }, deepResearchController.getTask);

View File

@@ -3,12 +3,16 @@
*
* 封装 create_task / query_task 两个 API。
* Worker 通过此客户端与 Unifuncs 服务交互。
*
* 模型版本通过环境变量 UNIFUNCS_MODEL 控制,默认 s3。
* 若 S3 出现问题,设置 UNIFUNCS_MODEL=s2 即可降级,无需改代码重部署。
*/
import { logger } from '../../../common/logging/index.js';
const UNIFUNCS_BASE_URL = 'https://api.unifuncs.com/deepsearch/v1';
const UNIFUNCS_API_KEY = process.env.UNIFUNCS_API_KEY;
const UNIFUNCS_MODEL = process.env.UNIFUNCS_MODEL || 's3';
export interface CreateTaskParams {
query: string;
@@ -58,6 +62,14 @@ export interface QueryTaskResponse {
total_tokens: number;
};
};
session?: {
session_id: string;
status: string;
model: string;
title: string;
summary: string;
question: string;
};
};
}
@@ -75,8 +87,10 @@ class UnifuncsAsyncClient {
throw new Error('UNIFUNCS_API_KEY not configured');
}
const payload = {
model: 's2',
const model = UNIFUNCS_MODEL;
const payload: Record<string, any> = {
model,
messages: [{ role: 'user', content: params.query }],
introduction: params.introduction || this.defaultIntroduction(),
max_depth: params.maxDepth ?? 25,
@@ -87,7 +101,12 @@ class UnifuncsAsyncClient {
...(params.outputPrompt ? { output_prompt: params.outputPrompt } : {}),
};
if (model === 's3') {
payload.language = 'zh';
}
logger.info('[UnifuncsClient] Creating task', {
model,
queryLen: params.query.length,
domainScope: payload.domain_scope,
maxDepth: payload.max_depth,

View File

@@ -3,12 +3,16 @@
*
* 通过 OpenAI 兼容协议的 SSE 流获取实时 reasoning_content 和 content。
* Worker 消费此流可实现逐条实时写入 executionLogs。
*
* 模型版本通过环境变量 UNIFUNCS_MODEL 控制,默认 s3。
* 若 S3 出现问题,设置 UNIFUNCS_MODEL=s2 即可降级,无需改代码重部署。
*/
import { logger } from '../../../common/logging/index.js';
const UNIFUNCS_BASE_URL = 'https://api.unifuncs.com/deepsearch/v1';
const UNIFUNCS_API_KEY = process.env.UNIFUNCS_API_KEY;
const UNIFUNCS_MODEL = process.env.UNIFUNCS_MODEL || 's3';
export interface SseStreamParams {
query: string;
@@ -34,8 +38,10 @@ export async function* streamDeepSearch(
const apiKey = UNIFUNCS_API_KEY;
if (!apiKey) throw new Error('UNIFUNCS_API_KEY not configured');
const model = UNIFUNCS_MODEL;
const payload: Record<string, any> = {
model: 's2',
model,
messages: [{ role: 'user', content: params.query }],
stream: true,
introduction: params.introduction || defaultIntroduction(),
@@ -45,11 +51,16 @@ export async function* streamDeepSearch(
reference_style: params.referenceStyle || 'link',
};
if (model === 's3') {
payload.language = 'zh';
}
if (params.outputPrompt) {
payload.output_prompt = params.outputPrompt;
}
logger.info('[UnifuncsSse] Starting SSE stream', {
model,
queryLen: params.query.length,
domainScope: payload.domain_scope,
maxDepth: payload.max_depth,

View File

@@ -311,6 +311,7 @@ export async function getTaskDetail(
completedAt: task.completedAt,
durationSeconds: task.durationSeconds,
errorMessage: task.errorMessage,
errorDetails: task.errorDetails ?? undefined,
},
});
} catch (error) {

View File

@@ -0,0 +1,75 @@
/**
* RVW稿件审查模块 - 临床专业评估服务
* @module rvw/services/clinicalService
*
* 使用 PromptService 获取 RVW_CLINICAL prompt
* 返回纯 Markdown 报告(无分数)
*/
import { LLMFactory } from '../../../common/llm/adapters/LLMFactory.js';
import { ModelType } from '../../../common/llm/adapters/types.js';
import { logger } from '../../../common/logging/index.js';
import { prisma } from '../../../config/database.js';
import { getPromptService } from '../../../common/prompt/index.js';
export interface ClinicalReviewResult {
report: string;
summary: string;
}
/**
* 临床专业评估
* @param text 稿件文本
* @param modelType 模型类型
* @param userId 用户ID用于灰度预览判断
* @returns 评估结果Markdown 报告 + 摘要)
*/
export async function reviewClinical(
text: string,
modelType: ModelType = 'deepseek-v3',
userId?: string
): Promise<ClinicalReviewResult> {
try {
const promptService = getPromptService(prisma);
const { content: systemPrompt, isDraft } = await promptService.get(
'RVW_CLINICAL',
{},
{ userId }
);
if (isDraft) {
logger.info('[RVW:Clinical] 使用 DRAFT 版本 Prompt调试模式', { userId });
}
const messages = [
{ role: 'system' as const, content: systemPrompt },
{ role: 'user' as const, content: `请对以下医学稿件进行临床专业评估:\n\n${text}` },
];
logger.info('[RVW:Clinical] 开始临床专业评估', { modelType });
const llmAdapter = LLMFactory.getAdapter(modelType);
const response = await llmAdapter.chat(messages, {
temperature: 0.3,
maxTokens: 8000,
});
const content = response.content ?? '';
logger.info('[RVW:Clinical] 评估完成', {
modelType,
responseLength: content.length,
});
// 提取摘要取第一段非标题文本最多200字
const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#'));
const summary = lines.length > 0
? lines[0].trim().substring(0, 200)
: '临床专业评估已完成';
return { report: content, summary };
} catch (error) {
logger.error('[RVW:Clinical] 临床专业评估失败', {
error: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
});
throw new Error(`临床专业评估失败: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}

View File

@@ -207,7 +207,7 @@ export async function runReview(params: RunReviewParams): Promise<{ jobId: strin
where: {
id: taskId,
// 只有当状态是 pending/completed/failed 时才允许启动
status: { in: ['pending', 'completed', 'failed'] }
status: { in: ['pending', 'completed', 'partial_completed', 'failed'] }
},
data: {
status: 'reviewing',
@@ -236,7 +236,7 @@ export async function runReview(params: RunReviewParams): Promise<{ jobId: strin
agents,
extractedText: task.extractedText,
modelType: (task.modelUsed || 'deepseek-v3') as ModelType,
__expireInSeconds: 10 * 60, // 10分钟超时审稿任务通常2-3分钟完成
__expireInSeconds: 15 * 60, // 15min: 串行(5min)+并行(5min)+提取+余量长文档可达8-10min
});
logger.info('[RVW] 审查任务已推送到队列', {
@@ -320,7 +320,7 @@ export async function getTaskList(params: TaskListParams): Promise<TaskListRespo
if (status === 'pending') {
where.status = { in: ['pending', 'extracting', 'reviewing', 'reviewing_editorial', 'reviewing_methodology'] };
} else if (status === 'completed') {
where.status = { in: ['completed', 'failed'] };
where.status = { in: ['completed', 'partial_completed', 'failed'] };
}
const [tasks, total] = await Promise.all([
@@ -406,25 +406,26 @@ export async function getTaskReport(userId: string, taskId: string): Promise<Rev
throw new Error('任务不存在或无权限访问');
}
if (task.status !== 'completed') {
if (task.status !== 'completed' && task.status !== 'partial_completed') {
throw new Error(`报告尚未完成,当前状态: ${task.status}`);
}
// 从 contextData 中提取 forensicsResultV2.0 Skills 架构)
const contextData = task.contextData as { forensicsResult?: unknown } | null;
// 从 contextData 中提取 forensicsResult 和 clinicalReviewV2.0 Skills 架构)
const contextData = task.contextData as { forensicsResult?: unknown; clinicalReview?: unknown } | null;
const forensicsResult = contextData?.forensicsResult ?? undefined;
const clinicalReview = contextData?.clinicalReview ?? undefined;
return {
taskId: task.id,
fileName: task.fileName,
wordCount: task.wordCount ?? undefined,
modelUsed: task.modelUsed ?? undefined,
// 🆕 直接使用新字段
selectedAgents: (task.selectedAgents || ['editorial', 'methodology']) as AgentType[],
overallScore: task.overallScore ?? undefined,
editorialReview: task.editorialReview as unknown as EditorialReview | undefined,
methodologyReview: task.methodologyReview as unknown as MethodologyReview | undefined,
forensicsResult: forensicsResult as ReviewReport['forensicsResult'],
clinicalReview: clinicalReview as ReviewReport['clinicalReview'],
completedAt: task.completedAt ?? undefined,
durationSeconds: task.durationSeconds ?? undefined,
};

View File

@@ -105,15 +105,15 @@ export function validateAgentSelection(agents: string[]): void {
throw new Error('请至少选择一个智能体');
}
const validAgents = ['editorial', 'methodology'];
const validAgents = ['editorial', 'methodology', 'clinical'];
for (const agent of agents) {
if (!validAgents.includes(agent)) {
throw new Error(`无效的智能体类型: ${agent}`);
}
}
if (agents.length > 2) {
throw new Error('最多只能选择2个智能体');
if (agents.length > 3) {
throw new Error('最多只能选择3个智能体');
}
}

View File

@@ -26,7 +26,7 @@ import { logger } from '../../../../common/logging/index.js';
* 默认执行器配置
*/
const DEFAULT_EXECUTOR_CONFIG: ExecutorConfig = {
defaultTimeout: 30000, // 30 秒
defaultTimeout: 60000, // 60s 回退值(正常由 Profile 指定,此为安全兜底)
maxRetries: 0,
retryDelay: 1000,
continueOnError: true,
@@ -120,22 +120,50 @@ export class SkillExecutor<TContext extends BaseSkillContext = SkillContext> {
}
}
} else {
// 多个 Skill — 并行执行
// 多个 Skill — 并行执行(使用 allSettled 保证故障隔离)
logger.info('[SkillExecutor] Executing parallel stage', {
taskId: context.taskId,
skillIds: stage.map(s => s.skillId),
});
const promises = stage.map(item => this.executePipelineItem(item, context, profile));
const stageResults = await Promise.all(promises);
const settled = await Promise.allSettled(promises);
for (let i = 0; i < stage.length; i++) {
const result = stageResults[i];
if (result) {
results.push(result);
context.previousResults.push(result);
const skill = SkillRegistry.get(stage[i].skillId);
if (skill) this.updateContextWithResult(context, skill, result);
const outcome = settled[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);
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: 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,
});
}
}
}
@@ -362,10 +390,13 @@ export class SkillExecutor<TContext extends BaseSkillContext = SkillContext> {
const skippedCount = results.filter(r => r.status === 'skipped').length;
const timeoutCount = results.filter(r => r.status === 'timeout').length;
// warning 表示"执行成功但发现数据问题",不是执行失败
const completedCount = successCount + warningCount;
let overallStatus: 'success' | 'partial' | 'failed';
if (errorCount === 0 && timeoutCount === 0) {
overallStatus = 'success';
} else if (successCount > 0) {
} else if (completedCount > 0) {
overallStatus = 'partial';
} else {
overallStatus = 'failed';

View File

@@ -28,20 +28,27 @@ export const DEFAULT_PROFILE: JournalProfile = {
checkLevel: 'L1_L2_L25',
tolerancePercent: 0.1,
},
timeout: 60000,
timeout: 300000, // 5min: Python + LLM核查(内部180s超时降级) + 长文档余量
},
{
skillId: 'EditorialSkill',
enabled: true,
optional: false,
timeout: 180000,
timeout: 300000, // 5min: 20+页稿件 LLM 可能需要 2-3 分钟
parallelGroup: 'llm-review',
},
{
skillId: 'MethodologySkill',
enabled: true,
optional: false,
timeout: 180000,
timeout: 300000, // 5min: 方法学分析耗时最长
parallelGroup: 'llm-review',
},
{
skillId: 'ClinicalAssessmentSkill',
enabled: true,
optional: true,
timeout: 300000, // 5min: 临床评估含 FINER 多维度分析
parallelGroup: 'llm-review',
},
],
@@ -71,7 +78,7 @@ export const CHINESE_CORE_PROFILE: JournalProfile = {
checkLevel: 'L1_L2_L25',
tolerancePercent: 0.05,
},
timeout: 60000,
timeout: 300000,
},
{
skillId: 'EditorialSkill',
@@ -80,21 +87,21 @@ export const CHINESE_CORE_PROFILE: JournalProfile = {
config: {
standard: 'chinese-core',
},
timeout: 180000,
timeout: 300000,
parallelGroup: 'llm-review',
},
{
skillId: 'MethodologySkill',
enabled: true,
optional: false,
timeout: 180000,
timeout: 300000,
parallelGroup: 'llm-review',
},
],
globalConfig: {
strictness: 'STRICT',
continueOnError: false, // 严格模式,失败即停止
continueOnError: false,
},
};
@@ -116,7 +123,7 @@ export const QUICK_FORENSICS_PROFILE: JournalProfile = {
checkLevel: 'L1_L2_L25',
tolerancePercent: 0.1,
},
timeout: 60000,
timeout: 300000,
},
],
@@ -141,6 +148,7 @@ const PROFILES: Map<string, JournalProfile> = new Map([
const AGENT_TO_SKILL_MAP: Record<string, string> = {
'editorial': 'EditorialSkill',
'methodology': 'MethodologySkill',
'clinical': 'ClinicalAssessmentSkill',
'forensics': 'DataForensicsSkill',
};

View File

@@ -105,6 +105,8 @@ export interface ForensicsResult {
errorCount: number;
warningCount: number;
};
llmReport?: string;
llmTableReports?: Record<string, string>;
}
/**

View File

@@ -0,0 +1,101 @@
/**
* RVW V2.0 Skills 架构 - 临床专业评估 Skill
*
* 封装 clinicalService对稿件进行临床研究设计评估
*
* @version 1.0.0
* @since 2026-03-07
*/
import { BaseSkill, ExecuteResult } from './BaseSkill.js';
import {
SkillMetadata,
SkillContext,
SkillConfig,
} from '../core/types.js';
import { reviewClinical, ClinicalReviewResult } from '../../services/clinicalService.js';
import { logger } from '../../../../common/logging/index.js';
import { z } from 'zod';
const DEFAULT_MAX_CONTENT_LENGTH = 100000;
export const ClinicalConfigSchema = z.object({
maxContentLength: z.number().default(100000),
});
export type ClinicalConfig = z.infer<typeof ClinicalConfigSchema>;
export class ClinicalAssessmentSkill extends BaseSkill<SkillContext, ClinicalConfig> {
readonly configSchema = ClinicalConfigSchema;
readonly metadata: SkillMetadata = {
id: 'ClinicalAssessmentSkill',
name: '临床专业评估',
description: '基于 FINER 标准对研究选题进行临床价值、创新性、科学性、可行性的系统评估',
version: '1.0.0',
category: 'methodology',
inputs: ['documentContent'],
outputs: ['clinicalResult'],
defaultTimeout: 300000, // 5min: FINER 多维度评估,长文档需要更长
retryable: true,
icon: '🏥',
color: '#eb2f96',
};
canRun(context: SkillContext): boolean {
if (!context.documentContent || context.documentContent.trim().length === 0) {
logger.warn('[ClinicalAssessmentSkill] No document content', { taskId: context.taskId });
return false;
}
if (context.documentContent.length > DEFAULT_MAX_CONTENT_LENGTH) {
logger.warn('[ClinicalAssessmentSkill] Content too long', {
taskId: context.taskId,
contentLength: context.documentContent.length,
limit: DEFAULT_MAX_CONTENT_LENGTH,
});
return false;
}
return true;
}
async execute(
context: SkillContext,
config?: ClinicalConfig
): Promise<ExecuteResult> {
const maxContentLength = config?.maxContentLength || DEFAULT_MAX_CONTENT_LENGTH;
logger.info('[ClinicalAssessmentSkill] Starting evaluation', {
taskId: context.taskId,
contentLength: context.documentContent.length,
});
let content = context.documentContent;
if (content.length > maxContentLength) {
content = content.substring(0, maxContentLength);
logger.warn('[ClinicalAssessmentSkill] Content truncated', {
taskId: context.taskId,
originalLength: context.documentContent.length,
truncatedLength: maxContentLength,
});
}
const result: ClinicalReviewResult = await reviewClinical(content, 'deepseek-v3', context.userId);
logger.info('[ClinicalAssessmentSkill] Evaluation completed', {
taskId: context.taskId,
reportLength: result.report.length,
});
return {
status: 'success',
issues: [],
data: result,
};
}
}
export const clinicalAssessmentSkill = new ClinicalAssessmentSkill();

View File

@@ -24,6 +24,9 @@ import {
} from '../../../../common/document/ExtractionClient.js';
import { storage } from '../../../../common/storage/index.js';
import { logger } from '../../../../common/logging/index.js';
import { LLMFactory } from '../../../../common/llm/adapters/LLMFactory.js';
import { prisma } from '../../../../config/database.js';
import { getPromptService } from '../../../../common/prompt/index.js';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
@@ -59,7 +62,7 @@ export class DataForensicsSkill extends BaseSkill<SkillContext, DataForensicsCon
inputs: ['documentPath'],
outputs: ['tables', 'methods', 'forensicsResult'],
defaultTimeout: 60000, // 60 秒
defaultTimeout: 300000, // 5min: Python + LLM核查(内部180s超时降级) + 长文档余量
retryable: true,
icon: '🐍',
@@ -152,6 +155,21 @@ export class DataForensicsSkill extends BaseSkill<SkillContext, DataForensicsCon
// 转换为内部格式
const forensicsResult = this.convertResult(result);
// 3. LLM 智能核查(批量发送所有表格)
if (forensicsResult.tables.length > 0) {
try {
await this.runLlmValidation(forensicsResult, context);
} catch (llmError) {
const errMsg = llmError instanceof Error ? llmError.message : String(llmError);
const isTimeout = errMsg.includes('LLM_VALIDATION_TIMEOUT');
logger.warn(`[DataForensicsSkill] LLM validation ${isTimeout ? 'timed out (180s)' : 'failed'}, falling back to rules only`, {
taskId: context.taskId,
error: errMsg,
tableCount: forensicsResult.tables.length,
});
}
}
// 计算状态和评分(基于数据质量结论,非执行状态;发现问题不等于执行失败)
const hasErrors = forensicsResult.summary.errorCount > 0;
const hasWarnings = forensicsResult.summary.warningCount > 0;
@@ -176,6 +194,7 @@ export class DataForensicsSkill extends BaseSkill<SkillContext, DataForensicsCon
issueCount: forensicsResult.summary.totalIssues,
errorCount: forensicsResult.summary.errorCount,
warningCount: forensicsResult.summary.warningCount,
hasLlmReport: !!forensicsResult.llmReport,
});
return {
@@ -245,6 +264,82 @@ export class DataForensicsSkill extends BaseSkill<SkillContext, DataForensicsCon
}
}
/**
* LLM 智能核查:将所有表格一次性发给 LLM 进行核查
* 结果写入 forensicsResult.llmReport 和 forensicsResult.llmTableReports
*/
private async runLlmValidation(
forensicsResult: ForensicsResult,
context: SkillContext
): Promise<void> {
logger.info('[DataForensicsSkill] Starting LLM validation', {
taskId: context.taskId,
tableCount: forensicsResult.tables.length,
});
const promptService = getPromptService(prisma);
const { content: systemPrompt } = await promptService.get(
'RVW_DATA_VALIDATION',
{},
{ userId: context.userId }
);
// 拼接所有表格为 user message
const tableTexts: string[] = [];
for (let i = 0; i < forensicsResult.tables.length; i++) {
const table = forensicsResult.tables[i];
const caption = table.caption || `表格 ${i + 1}`;
const dataText = (table.data || [])
.map(row => row.join('\t'))
.join('\n');
tableTexts.push(`## 表${i + 1}: ${caption}\n\n${dataText}`);
}
const userMessage = `以下是从医学科研稿件中提取的 ${forensicsResult.tables.length} 张表格,请逐表核查:\n\n${tableTexts.join('\n\n---\n\n')}`;
const messages = [
{ role: 'system' as const, content: systemPrompt },
{ role: 'user' as const, content: userMessage },
];
const llmAdapter = LLMFactory.getAdapter('deepseek-v3');
const LLM_TIMEOUT_MS = 180_000;
const response = await Promise.race([
llmAdapter.chat(messages, {
temperature: 0.3,
maxTokens: 8000,
}),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('LLM_VALIDATION_TIMEOUT')), LLM_TIMEOUT_MS)
),
]);
const llmReport = response.content ?? '';
logger.info('[DataForensicsSkill] LLM validation completed', {
taskId: context.taskId,
reportLength: llmReport.length,
tableCount: forensicsResult.tables.length,
});
forensicsResult.llmReport = llmReport;
// 按 "## 表N:" 分割,映射到各表格
forensicsResult.llmTableReports = {};
const sections = llmReport.split(/(?=^## 表\d+[:])/m);
for (const section of sections) {
const headerMatch = section.match(/^## 表(\d+)[:]\s*(.*)/m);
if (headerMatch) {
const tableIdx = parseInt(headerMatch[1], 10) - 1;
if (tableIdx >= 0 && tableIdx < forensicsResult.tables.length) {
const tableId = forensicsResult.tables[tableIdx].id;
forensicsResult.llmTableReports[tableId] = section.trim();
}
}
}
}
/**
* 转换 Python 返回的结果为内部格式
*

View File

@@ -44,7 +44,7 @@ export class EditorialSkill extends BaseSkill<SkillContext, EditorialConfig> {
inputs: ['documentContent'],
outputs: ['editorialResult'],
defaultTimeout: 180000, // 180 秒LLM 调用可能较慢)
defaultTimeout: 300000, // 5min: 20+页长文档 LLM 处理需要更长时间
retryable: true,
icon: '📋',

View File

@@ -44,7 +44,7 @@ export class MethodologySkill extends BaseSkill<SkillContext, MethodologyConfig>
inputs: ['documentContent', 'methods'],
outputs: ['methodologyResult'],
defaultTimeout: 180000, // 180 秒(方法学分析需要更长时间)
defaultTimeout: 300000, // 5min: 方法学分析最耗时,长文档可达 2-3 分钟
retryable: true,
icon: '🔬',

View File

@@ -11,6 +11,7 @@ import { SkillRegistry } from '../core/registry.js';
import { dataForensicsSkill, DataForensicsSkill } from './DataForensicsSkill.js';
import { editorialSkill, EditorialSkill } from './EditorialSkill.js';
import { methodologySkill, MethodologySkill } from './MethodologySkill.js';
import { clinicalAssessmentSkill, ClinicalAssessmentSkill } from './ClinicalAssessmentSkill.js';
/**
* 注册所有内置 Skills
@@ -20,6 +21,7 @@ export function registerBuiltinSkills(): void {
dataForensicsSkill,
editorialSkill,
methodologySkill,
clinicalAssessmentSkill,
]);
SkillRegistry.markInitialized();
@@ -33,6 +35,7 @@ export function getBuiltinSkills() {
dataForensicsSkill,
editorialSkill,
methodologySkill,
clinicalAssessmentSkill,
];
}
@@ -41,6 +44,7 @@ export {
DataForensicsSkill,
EditorialSkill,
MethodologySkill,
ClinicalAssessmentSkill,
};
// 导出单例(用于直接调用)
@@ -48,6 +52,7 @@ export {
dataForensicsSkill,
editorialSkill,
methodologySkill,
clinicalAssessmentSkill,
};
// 导出基类

View File

@@ -10,17 +10,18 @@
* - editorial: 稿约规范性智能体
* - methodology: 方法学统计智能体
*/
export type AgentType = 'editorial' | 'methodology';
export type AgentType = 'editorial' | 'methodology' | 'clinical';
/**
* 任务状态
*/
export type TaskStatus =
| 'pending' // 待处理
| 'extracting' // 正在提取文档
| 'reviewing' // 正在评估
| 'completed' // 已完成
| 'failed'; // 失败
| 'pending' // 待处理
| 'extracting' // 正在提取文档
| 'reviewing' // 正在评估
| 'completed' // 已完成
| 'partial_completed' // 部分完成(部分模块成功,部分失败/超时)
| 'failed'; // 失败
/**
* 方法学评估状态
@@ -102,6 +103,8 @@ export interface ForensicsResult {
errorCount: number;
warningCount: number;
};
llmReport?: string;
llmTableReports?: Record<string, string>;
}
// ==================== 请求参数 ====================
@@ -172,6 +175,11 @@ export interface TaskListResponse {
/**
* 完整报告
*/
export interface ClinicalReviewResult {
report: string;
summary: string;
}
export interface ReviewReport {
taskId: string;
fileName: string;
@@ -182,6 +190,7 @@ export interface ReviewReport {
editorialReview?: EditorialReview;
methodologyReview?: MethodologyReview;
forensicsResult?: ForensicsResult;
clinicalReview?: ClinicalReviewResult;
completedAt?: Date;
durationSeconds?: number;
}

View File

@@ -24,7 +24,7 @@ import { ModelType } from '../../../common/llm/adapters/types.js';
import { reviewEditorialStandards } from '../services/editorialService.js';
import { reviewMethodology } from '../services/methodologyService.js';
import { calculateOverallScore, getMethodologyStatus } from '../services/utils.js';
import type { AgentType, EditorialReview, MethodologyReview } from '../types/index.js';
import type { AgentType, EditorialReview, MethodologyReview, ClinicalReviewResult } from '../types/index.js';
import { activityService } from '../../../common/services/activity.service.js';
// V2.0 Skills 架构导入
@@ -176,6 +176,7 @@ export async function registerReviewWorker() {
// ========================================
let editorialResult: EditorialReview | null = null;
let methodologyResult: MethodologyReview | null = null;
let clinicalResult: ClinicalReviewResult | null = null;
let skillsSummary: ExecutionSummary | null = null;
if (USE_SKILLS_ARCHITECTURE) {
@@ -195,6 +196,7 @@ export async function registerReviewWorker() {
// 从 Skills 结果中提取兼容数据
const editorialSkillResult = skillsSummary.results.find(r => r.skillId === 'EditorialSkill');
const methodologySkillResult = skillsSummary.results.find(r => r.skillId === 'MethodologySkill');
const clinicalSkillResult = skillsSummary.results.find(r => r.skillId === 'ClinicalAssessmentSkill');
if (editorialSkillResult?.status !== 'skipped' && editorialSkillResult?.data) {
editorialResult = editorialSkillResult.data as EditorialReview;
@@ -202,6 +204,9 @@ export async function registerReviewWorker() {
if (methodologySkillResult?.status !== 'skipped' && methodologySkillResult?.data) {
methodologyResult = methodologySkillResult.data as MethodologyReview;
}
if (clinicalSkillResult?.status !== 'skipped' && clinicalSkillResult?.data) {
clinicalResult = clinicalSkillResult.data as ClinicalReviewResult;
}
logger.info('[reviewWorker] Skills execution completed', {
taskId,
@@ -261,10 +266,32 @@ export async function registerReviewWorker() {
const endTime = Date.now();
const durationSeconds = Math.floor((endTime - startTime) / 1000);
// ========================================
// 判断最终状态completed / partial_completed / failed
// ========================================
let finalStatus: 'completed' | 'partial_completed' | 'failed' = 'completed';
let errorDetails: Record<string, unknown> | null = null;
if (USE_SKILLS_ARCHITECTURE && skillsSummary) {
if (skillsSummary.overallStatus === 'partial') {
finalStatus = 'partial_completed';
errorDetails = buildErrorDetails(skillsSummary);
logger.warn('[reviewWorker] Partial completion detected', {
taskId,
successCount: skillsSummary.successCount,
errorCount: skillsSummary.errorCount,
timeoutCount: skillsSummary.timeoutCount,
});
} else if (skillsSummary.overallStatus === 'failed') {
finalStatus = 'failed';
errorDetails = buildErrorDetails(skillsSummary);
}
}
// ========================================
// 更新任务结果
// ========================================
logger.info('[reviewWorker] Updating task result', { taskId });
logger.info('[reviewWorker] Updating task result', { taskId, finalStatus });
// 构建 Skills 执行摘要V2.0 新增,存储到专用 contextData 字段)
const skillsContext = USE_SKILLS_ARCHITECTURE && skillsSummary
@@ -279,13 +306,14 @@ export async function registerReviewWorker() {
totalExecutionTime: skillsSummary.totalExecutionTime,
},
forensicsResult: skillsSummary.results.find(r => r.skillId === 'DataForensicsSkill')?.data,
clinicalReview: clinicalResult,
}
: null;
await prisma.reviewTask.update({
where: { id: taskId },
data: {
status: 'completed',
status: finalStatus,
editorialReview: editorialResult as unknown as Prisma.InputJsonValue ?? Prisma.JsonNull,
methodologyReview: methodologyResult as unknown as Prisma.InputJsonValue ?? Prisma.JsonNull,
contextData: skillsContext as unknown as Prisma.InputJsonValue ?? Prisma.JsonNull,
@@ -295,6 +323,7 @@ export async function registerReviewWorker() {
methodologyStatus: getMethodologyStatus(methodologyResult),
completedAt: new Date(),
durationSeconds,
errorDetails: errorDetails as unknown as Prisma.InputJsonValue ?? Prisma.DbNull,
},
});
@@ -302,6 +331,7 @@ export async function registerReviewWorker() {
jobId: job.id,
taskId,
agents,
finalStatus,
editorialScore,
methodologyScore,
overallScore,
@@ -309,7 +339,8 @@ export async function registerReviewWorker() {
architecture: USE_SKILLS_ARCHITECTURE ? 'skills' : 'legacy',
});
console.log('\n✅ 审查完成:');
const statusEmoji = finalStatus === 'completed' ? '✅' : finalStatus === 'partial_completed' ? '⚠️' : '❌';
console.log(`\n${statusEmoji} 审查完成 (${finalStatus}):`);
console.log(` Task ID: ${taskId}`);
console.log(` 综合得分: ${overallScore}`);
console.log(` 耗时: ${durationSeconds}`);
@@ -324,7 +355,7 @@ export async function registerReviewWorker() {
});
if (user) {
const agentNames = agents.map(a => a === 'editorial' ? '稿约规范性' : '方法学').join('+');
const agentNames = agents.map(a => a === 'editorial' ? '稿约规范性' : a === 'methodology' ? '方法学' : '临床评估').join('+');
activityService.log(
user.tenant_id,
user.tenants?.name || null,
@@ -346,7 +377,8 @@ export async function registerReviewWorker() {
editorialScore,
methodologyScore,
durationSeconds,
success: true,
success: finalStatus !== 'failed',
status: finalStatus,
architecture: USE_SKILLS_ARCHITECTURE ? 'skills' : 'legacy',
};
} catch (error: unknown) {
@@ -379,6 +411,34 @@ export async function registerReviewWorker() {
logger.info('[reviewWorker] ✅ Worker registered: rvw_review_task');
}
/**
* 构建 errorDetails JSON记录每个 Skill 的执行结果
* 用于前端展示"哪个模块失败了、原因是什么"
*/
function buildErrorDetails(summary: ExecutionSummary): Record<string, unknown> {
const skills: Record<string, unknown>[] = [];
for (const result of summary.results) {
if (result.status === 'error' || result.status === 'timeout') {
skills.push({
skillId: result.skillId,
skillName: result.skillName,
status: result.status,
error: result.error || (result.status === 'timeout' ? '执行超时' : '未知错误'),
executionTime: result.executionTime,
});
}
}
return {
failedSkills: skills,
successCount: summary.successCount + summary.warningCount,
errorCount: summary.errorCount,
timeoutCount: summary.timeoutCount ?? 0,
totalSkills: summary.totalSkills,
};
}
/**
* 使用 V2.0 Skills 架构执行审查
*/