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:
@@ -0,0 +1,3 @@
|
||||
-- AlterTable: ReviewTask 新增 error_details 字段
|
||||
-- 用于存储结构化的错误详情(各 Skill 成功/失败状态),支持 partial_completed 场景
|
||||
ALTER TABLE "rvw_schema"."review_tasks" ADD COLUMN "error_details" JSONB;
|
||||
@@ -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")
|
||||
|
||||
@@ -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} 字符`);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -311,6 +311,7 @@ export async function getTaskDetail(
|
||||
completedAt: task.completedAt,
|
||||
durationSeconds: task.durationSeconds,
|
||||
errorMessage: task.errorMessage,
|
||||
errorDetails: task.errorDetails ?? undefined,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
75
backend/src/modules/rvw/services/clinicalService.ts
Normal file
75
backend/src/modules/rvw/services/clinicalService.ts
Normal 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'}`);
|
||||
}
|
||||
}
|
||||
@@ -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 中提取 forensicsResult(V2.0 Skills 架构)
|
||||
const contextData = task.contextData as { forensicsResult?: unknown } | null;
|
||||
// 从 contextData 中提取 forensicsResult 和 clinicalReview(V2.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,
|
||||
};
|
||||
|
||||
@@ -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个智能体');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -105,6 +105,8 @@ export interface ForensicsResult {
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
};
|
||||
llmReport?: string;
|
||||
llmTableReports?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
@@ -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 返回的结果为内部格式
|
||||
*
|
||||
|
||||
@@ -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: '📋',
|
||||
|
||||
@@ -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: '🔬',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
// 导出基类
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 架构执行审查
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user