diff --git a/backend/prisma/migrations/20260307_add_error_details_to_review_task/migration.sql b/backend/prisma/migrations/20260307_add_error_details_to_review_task/migration.sql new file mode 100644 index 00000000..259703a5 --- /dev/null +++ b/backend/prisma/migrations/20260307_add_error_details_to_review_task/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable: ReviewTask 新增 error_details 字段 +-- 用于存储结构化的错误详情(各 Skill 成功/失败状态),支持 partial_completed 场景 +ALTER TABLE "rvw_schema"."review_tasks" ADD COLUMN "error_details" JSONB; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 04473c03..5ab6e08f 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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") diff --git a/backend/scripts/migrate-rvw-prompts.ts b/backend/scripts/migrate-rvw-prompts.ts index 42e52d38..dbf8d1f4 100644 --- a/backend/scripts/migrate-rvw-prompts.ts +++ b/backend/scripts/migrate-rvw-prompts.ts @@ -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} 字符`); diff --git a/backend/scripts/seed-modules.js b/backend/scripts/seed-modules.js index c204107d..e4ee1f3b 100644 --- a/backend/scripts/seed-modules.js +++ b/backend/scripts/seed-modules.js @@ -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() { diff --git a/backend/src/common/prompt/prompt.fallbacks.ts b/backend/src/common/prompt/prompt.fallbacks.ts index 83eaa840..46c28961 100644 --- a/backend/src/common/prompt/prompt.fallbacks.ts +++ b/backend/src/common/prompt/prompt.fallbacks.ts @@ -52,6 +52,49 @@ const RVW_FALLBACKS: Record = { 请输出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 }, + }, }; /** diff --git a/backend/src/modules/asl/charting/routes/index.ts b/backend/src/modules/asl/charting/routes/index.ts index 880c7428..77d66851 100644 --- a/backend/src/modules/asl/charting/routes/index.ts +++ b/backend/src/modules/asl/charting/routes/index.ts @@ -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); diff --git a/backend/src/modules/asl/controllers/deepResearchController.ts b/backend/src/modules/asl/controllers/deepResearchController.ts index 928a227f..3be9e614 100644 --- a/backend/src/modules/asl/controllers/deepResearchController.ts +++ b/backend/src/modules/asl/controllers/deepResearchController.ts @@ -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: { diff --git a/backend/src/modules/asl/extraction/routes/index.ts b/backend/src/modules/asl/extraction/routes/index.ts index 774c4c43..0a34f866 100644 --- a/backend/src/modules/asl/extraction/routes/index.ts +++ b/backend/src/modules/asl/extraction/routes/index.ts @@ -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); diff --git a/backend/src/modules/asl/meta-analysis/routes/index.ts b/backend/src/modules/asl/meta-analysis/routes/index.ts index 1e646cb2..45f36668 100644 --- a/backend/src/modules/asl/meta-analysis/routes/index.ts +++ b/backend/src/modules/asl/meta-analysis/routes/index.ts @@ -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); diff --git a/backend/src/modules/asl/routes/index.ts b/backend/src/modules/asl/routes/index.ts index 7aa15ac6..c9bd80a8 100644 --- a/backend/src/modules/asl/routes/index.ts +++ b/backend/src/modules/asl/routes/index.ts @@ -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); diff --git a/backend/src/modules/asl/services/unifuncsAsyncClient.ts b/backend/src/modules/asl/services/unifuncsAsyncClient.ts index 1394c29a..831cad98 100644 --- a/backend/src/modules/asl/services/unifuncsAsyncClient.ts +++ b/backend/src/modules/asl/services/unifuncsAsyncClient.ts @@ -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 = { + 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, diff --git a/backend/src/modules/asl/services/unifuncsSseClient.ts b/backend/src/modules/asl/services/unifuncsSseClient.ts index 91613871..9cf18253 100644 --- a/backend/src/modules/asl/services/unifuncsSseClient.ts +++ b/backend/src/modules/asl/services/unifuncsSseClient.ts @@ -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 = { - 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, diff --git a/backend/src/modules/rvw/controllers/reviewController.ts b/backend/src/modules/rvw/controllers/reviewController.ts index be2d47b8..2cdc21dd 100644 --- a/backend/src/modules/rvw/controllers/reviewController.ts +++ b/backend/src/modules/rvw/controllers/reviewController.ts @@ -311,6 +311,7 @@ export async function getTaskDetail( completedAt: task.completedAt, durationSeconds: task.durationSeconds, errorMessage: task.errorMessage, + errorDetails: task.errorDetails ?? undefined, }, }); } catch (error) { diff --git a/backend/src/modules/rvw/services/clinicalService.ts b/backend/src/modules/rvw/services/clinicalService.ts new file mode 100644 index 00000000..3968999e --- /dev/null +++ b/backend/src/modules/rvw/services/clinicalService.ts @@ -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 { + 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'}`); + } +} diff --git a/backend/src/modules/rvw/services/reviewService.ts b/backend/src/modules/rvw/services/reviewService.ts index da4f97b2..9a83e9d4 100644 --- a/backend/src/modules/rvw/services/reviewService.ts +++ b/backend/src/modules/rvw/services/reviewService.ts @@ -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 2) { - throw new Error('最多只能选择2个智能体'); + if (agents.length > 3) { + throw new Error('最多只能选择3个智能体'); } } diff --git a/backend/src/modules/rvw/skills/core/executor.ts b/backend/src/modules/rvw/skills/core/executor.ts index 5e569e63..430a1c4c 100644 --- a/backend/src/modules/rvw/skills/core/executor.ts +++ b/backend/src/modules/rvw/skills/core/executor.ts @@ -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 { } } } 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 { 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'; diff --git a/backend/src/modules/rvw/skills/core/profile.ts b/backend/src/modules/rvw/skills/core/profile.ts index 227ef03c..17869ba5 100644 --- a/backend/src/modules/rvw/skills/core/profile.ts +++ b/backend/src/modules/rvw/skills/core/profile.ts @@ -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 = new Map([ const AGENT_TO_SKILL_MAP: Record = { 'editorial': 'EditorialSkill', 'methodology': 'MethodologySkill', + 'clinical': 'ClinicalAssessmentSkill', 'forensics': 'DataForensicsSkill', }; diff --git a/backend/src/modules/rvw/skills/core/types.ts b/backend/src/modules/rvw/skills/core/types.ts index cb6b4d01..271002cd 100644 --- a/backend/src/modules/rvw/skills/core/types.ts +++ b/backend/src/modules/rvw/skills/core/types.ts @@ -105,6 +105,8 @@ export interface ForensicsResult { errorCount: number; warningCount: number; }; + llmReport?: string; + llmTableReports?: Record; } /** diff --git a/backend/src/modules/rvw/skills/library/ClinicalAssessmentSkill.ts b/backend/src/modules/rvw/skills/library/ClinicalAssessmentSkill.ts new file mode 100644 index 00000000..7b7a5e05 --- /dev/null +++ b/backend/src/modules/rvw/skills/library/ClinicalAssessmentSkill.ts @@ -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; + +export class ClinicalAssessmentSkill extends BaseSkill { + 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 { + 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(); diff --git a/backend/src/modules/rvw/skills/library/DataForensicsSkill.ts b/backend/src/modules/rvw/skills/library/DataForensicsSkill.ts index 077107da..c460957c 100644 --- a/backend/src/modules/rvw/skills/library/DataForensicsSkill.ts +++ b/backend/src/modules/rvw/skills/library/DataForensicsSkill.ts @@ -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 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 { + 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((_, 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 返回的结果为内部格式 * diff --git a/backend/src/modules/rvw/skills/library/EditorialSkill.ts b/backend/src/modules/rvw/skills/library/EditorialSkill.ts index 523fa764..a2e7feb9 100644 --- a/backend/src/modules/rvw/skills/library/EditorialSkill.ts +++ b/backend/src/modules/rvw/skills/library/EditorialSkill.ts @@ -44,7 +44,7 @@ export class EditorialSkill extends BaseSkill { inputs: ['documentContent'], outputs: ['editorialResult'], - defaultTimeout: 180000, // 180 秒(LLM 调用可能较慢) + defaultTimeout: 300000, // 5min: 20+页长文档 LLM 处理需要更长时间 retryable: true, icon: '📋', diff --git a/backend/src/modules/rvw/skills/library/MethodologySkill.ts b/backend/src/modules/rvw/skills/library/MethodologySkill.ts index 5c673209..61543aa0 100644 --- a/backend/src/modules/rvw/skills/library/MethodologySkill.ts +++ b/backend/src/modules/rvw/skills/library/MethodologySkill.ts @@ -44,7 +44,7 @@ export class MethodologySkill extends BaseSkill inputs: ['documentContent', 'methods'], outputs: ['methodologyResult'], - defaultTimeout: 180000, // 180 秒(方法学分析需要更长时间) + defaultTimeout: 300000, // 5min: 方法学分析最耗时,长文档可达 2-3 分钟 retryable: true, icon: '🔬', diff --git a/backend/src/modules/rvw/skills/library/index.ts b/backend/src/modules/rvw/skills/library/index.ts index acc49a83..a8b22943 100644 --- a/backend/src/modules/rvw/skills/library/index.ts +++ b/backend/src/modules/rvw/skills/library/index.ts @@ -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, }; // 导出基类 diff --git a/backend/src/modules/rvw/types/index.ts b/backend/src/modules/rvw/types/index.ts index f244bec4..08310c57 100644 --- a/backend/src/modules/rvw/types/index.ts +++ b/backend/src/modules/rvw/types/index.ts @@ -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; } // ==================== 请求参数 ==================== @@ -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; } diff --git a/backend/src/modules/rvw/workers/reviewWorker.ts b/backend/src/modules/rvw/workers/reviewWorker.ts index 8caaf0c0..2c6f2f7a 100644 --- a/backend/src/modules/rvw/workers/reviewWorker.ts +++ b/backend/src/modules/rvw/workers/reviewWorker.ts @@ -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 | 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 { + const skills: Record[] = []; + + 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 架构执行审查 */ diff --git a/docs/03-业务模块/ASL-AI智能文献/03-UI设计/v6.html b/docs/03-业务模块/ASL-AI智能文献/03-UI设计/v6.html new file mode 100644 index 00000000..bb55123b --- /dev/null +++ b/docs/03-业务模块/ASL-AI智能文献/03-UI设计/v6.html @@ -0,0 +1,541 @@ + + + + + + AI智能文献平台 v6.0 (互斥手风琴版) + + + + + + + + + +
+
+
+
+ 医学科研 AI 平台 +
+ +
+
+ +
Dr
+
+
+ +
+ + + + +
+ + +
+ + + + + +
+ + +
+ + +
+ + + + +
+
+
+ 我的工作区 + +
+ +
+ +
+ +
+ +
+
+
+
+ + +
+ + +
+
+ DeepSearch 配额 + 45k/100k +
+
+
+
+
+
+ + +
+ +
+
+ + + + \ No newline at end of file diff --git a/docs/03-业务模块/RVW-稿件审查系统/00-模块当前状态与开发指南.md b/docs/03-业务模块/RVW-稿件审查系统/00-模块当前状态与开发指南.md index 6f1967bc..ff0f509c 100644 --- a/docs/03-业务模块/RVW-稿件审查系统/00-模块当前状态与开发指南.md +++ b/docs/03-业务模块/RVW-稿件审查系统/00-模块当前状态与开发指南.md @@ -1,30 +1,25 @@ # RVW稿件审查模块 - 当前状态与开发指南 -> **文档版本:** v5.1 +> **文档版本:** v6.0 > **创建日期:** 2026-01-07 -> **最后更新:** 2026-02-18 +> **最后更新:** 2026-03-07 > **维护者:** 开发团队 -> **当前状态:** 🚀 **V2.0 "数据侦探" Week 3 完成(统计验证扩展+用户体验优化)** +> **当前状态:** 🚀 **V3.0 "智能审稿增强" 完成(LLM数据核查 + 临床评估 + 稳定性增强)** > **文档目的:** 快速了解RVW模块状态,为新AI助手提供上下文 > -> **🎉 V2.0 进展(2026-02-18 Week 3):** -> - ✅ **负号归一化**:防止 float() 崩溃,覆盖 6 种负号变体 -> - ✅ **T 检验验证增强**:智能样本量提取 + subrow 精确高亮 -> - ✅ **SE 三角验证增强**:多行单元格 subrow 支持 -> - ✅ **CI vs P 值验证增强**:subrow 支持 + 灵活 P 值解析 -> - ✅ **前端翻译映射**:新增 6 种 IssueType 中文翻译 -> - ✅ **文件格式提示**:PDF/.doc 上传时提示无法数据验证 +> **🎉 V3.0 进展(2026-03-07):** +> - ✅ **LLM 数据核查**:DataForensicsSkill 增加 LLM 验证通道,规则验证兜底,独立 60s 超时 +> - ✅ **临床专业评估**:新增 ClinicalAssessmentSkill,基于 FINER 标准的研究选题系统评估 +> - ✅ **PromptService 集成**:RVW_DATA_VALIDATION + RVW_CLINICAL 两个 Prompt 可在运营管理端配置 +> - ✅ **稳定性增强**:SkillExecutor 使用 Promise.allSettled 实现并行故障隔离 +> - ✅ **部分完成支持**:新增 `partial_completed` 状态 + `errorDetails` 字段,部分模块失败仍展示成功结果 +> - ✅ **前端 4 Tab 报告**:稿约规范性 / 方法学 / 数据验证 / 临床评估,Word 导出全覆盖 > -> **🎉 V2.0 进展(Week 1-2):** -> - ✅ **L1 算术验证器**:行列加总、百分比验证(Day 3) -> - ✅ **L2 统计验证器**:CI↔P 值一致性、卡方检验逆向验证(Day 6) -> - ✅ **L2.5 一致性取证**:SE三角验证、SD>Mean检查(Day 6 终审提权) -> - ✅ **Word 文档解析**:python-docx 表格提取 + 特殊符号提取(Day 2) -> - ✅ **Skills 核心框架**:types、registry、executor、profile、context(Day 7) -> - ✅ **DataForensicsSkill**:OSS 集成、依赖注入、优雅降级(Day 8) -> - ✅ **EditorialSkill + MethodologySkill**:封装现有服务(Day 9) -> - ✅ **ReviewWorker 改造**:集成 SkillExecutor,支持 V1/V2 切换(Day 10) -> - ✅ **前端数据验证 Tab**:ForensicsReport 组件、精确单元格高亮(Week 3) +> **V2.0 进展回顾:** +> - ✅ L1 算术验证 + L2 统计验证 + L2.5 一致性取证 +> - ✅ Skills 核心框架(types, registry, executor, profile) +> - ✅ DataForensicsSkill + EditorialSkill + MethodologySkill +> - ✅ ReviewWorker 改造 + 前端数据验证 Tab --- @@ -39,13 +34,13 @@ | **商业价值** | ⭐⭐⭐⭐⭐ 极高 | | **独立性** | ⭐⭐⭐⭐⭐ 极高(用户群完全不同) | | **目标用户** | 期刊初审编辑 | -| **开发状态** | ✅ **核心功能100%完成,支持Word导出,已集成到 frontend-v2** | +| **开发状态** | ✅ **V3.0 完成:4维审查(规范性+方法学+数据验证+临床评估)+ 稳定性增强 + Word导出** | ### 核心目标 -> 打造一个**"开箱即用"**的智能审稿工具。编辑上传稿件,系统自动运行双重检查(规范性+方法学),输出可供参考的审稿报告。 +> 打造一个**"开箱即用"**的智能审稿工具。编辑上传稿件,系统自动运行多维检查(规范性+方法学+数据验证+临床评估),输出可供参考的审稿报告。 > -> **核心指标**:上传到出报告 < 2分钟;规范性问题检出率 > 80% +> **核心指标**:上传到出报告 < 3分钟(4模块并行);规范性问题检出率 > 80% ### 功能规格 @@ -60,7 +55,19 @@ - 统计学方法描述评估(5个检查点) - 统计分析评估(6个检查点) -3. **综合评分 + PICO提取** +3. **数据验证**(V2.0 规则 + V3.0 LLM 双通道) + - L1 算术验证:行列加总、百分比计算 + - L2 统计验证:CI↔P 一致性、T检验、卡方检验 + - L2.5 一致性取证:SE三角验证、SD>Mean检查 + - LLM 智能核查:批量表格发给 LLM 核查(独立 60s 超时,失败降级为纯规则验证) + +4. **临床专业评估**(V3.0 新增) + - 基于 FINER 标准(可行性/创新性/伦理性/相关性) + - 研究问题明确性评估(PICO 完整性) + - 创新性 + 临床价值 + 科学性 + 可行性多维分析 + - Prompt 可在运营管理端配置(RVW_CLINICAL) + +5. **综合评分 + PICO提取** - 规范性分数(0-100) - 方法学状态(🔴错误 🟡存疑 🟢通过) - PICO结构化提取(P/I/C/O) @@ -95,14 +102,15 @@ backend/src/modules/rvw/ │ ├── reviewService.ts # 核心服务(任务创建、执行) │ ├── editorialService.ts # 稿约规范性评估 │ ├── methodologyService.ts # 方法学评估 +│ ├── clinicalService.ts # 🆕 V3.0 临床专业评估服务 │ └── utils.ts # 工具函数 ├── workers/ -│ └── reviewWorker.ts # pg-boss异步任务处理(V2.0 Skills集成) -├── skills/ # 🆕 V2.0 Skills 架构 -│ ├── core/ # 核心框架(types, registry, executor等) -│ ├── library/ # Skill 实现(Forensics, Editorial, Methodology) +│ └── reviewWorker.ts # pg-boss异步任务处理(V2.0 Skills集成 + V3.0 partial_completed) +├── skills/ # V2.0 Skills 架构 +│ ├── core/ # 核心框架(types, registry, executor[allSettled]等) +│ ├── library/ # Skill 实现(Forensics[+LLM], Editorial, Methodology, 🆕Clinical) │ └── index.ts # 模块入口 -├── types/index.ts # TypeScript类型定义 +├── types/index.ts # TypeScript类型定义(含 partial_completed 状态) └── __tests__/ # API测试脚本 前端(✅ 已完成): @@ -116,11 +124,13 @@ frontend-v2/src/modules/rvw/ └── components/ ├── Header.tsx # 页头(上传按钮) ├── Sidebar.tsx # 侧边栏导航 - ├── TaskTable.tsx # 任务列表表格 - ├── TaskDetail.tsx # 任务详情(进度条+报告+Word导出) + ├── TaskTable.tsx # 任务列表表格(支持 partial_completed 状态) + ├── TaskDetail.tsx # 任务详情(进度条+报告+Word导出+部分完成警告) ├── EditorialReport.tsx # 稿约规范性报告 ├── MethodologyReport.tsx # 方法学评估报告 - ├── AgentModal.tsx # 智能体选择弹窗 + ├── ForensicsReport.tsx # 数据验证报告(含 LLM 核查结果) + ├── ClinicalReport.tsx # 🆕 V3.0 临床专业评估报告 + ├── AgentModal.tsx # 智能体选择弹窗(4 个维度) └── ScoreRing.tsx # 评分环组件 旧版本(保留兼容): @@ -129,13 +139,17 @@ backend/src/legacy/ ├── controllers/reviewController.ts └── services/reviewService.ts -Prompt(保持不变): +Prompt(文件 + PromptService 数据库双通道): backend/prompts/ ├── review_editorial_system.txt # 稿约评估(266行) └── review_methodology_system.txt # 方法学评估(257行) +数据库 prompt_templates 表(运营管理端可配置): +├── RVW_DATA_VALIDATION # 🆕 V3.0 数据验证 LLM 核查 Prompt +└── RVW_CLINICAL # 🆕 V3.0 临床专业评估 Prompt 数据库(✅ 已完成): - ReviewTask表已添加新字段:selectedAgents, editorialScore, methodologyScore, methodologyStatus, picoExtract, isArchived, archivedAt +- 🆕 V3.0 新增 error_details JSONB 字段(Skill 级失败详情,支持 partial_completed 状态) - ✅ Schema已迁移到 rvw_schema(2026-01-10完成) ``` @@ -211,11 +225,12 @@ backend/prompts/ | 能力 | 位置 | 用途 | |------|------|------| -| **LLM网关** | `@/common/llm/LLMFactory` | AI评估 | +| **LLM网关** | `@/common/llm/LLMFactory` | AI评估(稿约/方法学/数据核查/临床评估) | +| **PromptService** | `@/common/prompt` | 🆕 V3.0 Prompt 管理(灰度预览、运营端配置) | | **文档处理** | `ExtractionClient` | Word/PDF文本提取 | -| **存储** | `@/common/storage` | 文件存储 | +| **存储** | `@/common/storage` | 文件存储(OSS/本地) | | **日志** | `@/common/logging` | 结构化日志 | -| **任务队列** | `jobQueue` | 异步任务处理 | +| **任务队列** | `jobQueue` | 异步任务处理(pg-boss) | ### LLM模型 @@ -326,11 +341,13 @@ Content-Type: multipart/form-data ### 对新AI助手 -1. ✅ **核心功能已完成**:前后端已迁移到新架构,可正常使用 +1. ✅ **V3.0 已完成**:4 维审查 + 稳定性增强 + partial_completed 支持 2. ✅ **已集成到 frontend-v2**:通过顶部导航栏"预审稿"进入 3. ✅ **v2 API 已就绪**:/api/v2/rvw/* 路由可用 4. ✅ **遵循云原生规范**:使用 logger 服务替代 console.log -5. ⚠️ **保留旧API**:v1路由保持兼容,支持旧前端 +5. ✅ **PromptService 集成**:RVW_DATA_VALIDATION + RVW_CLINICAL Prompt 可在运营管理端配置 +6. ✅ **并行故障隔离**:SkillExecutor 使用 Promise.allSettled,单个 Skill 崩溃不影响其他 +7. ⚠️ **保留旧API**:v1路由保持兼容,支持旧前端 ### 已完成改造 @@ -414,9 +431,25 @@ Content-Type: multipart/form-data - ✅ 前端翻译映射更新(6 种新 IssueType) - ✅ 文件格式提示(Header、ReportDetail、TaskDetail) -### 后续版本(V2.1+) +### 🚀 V3.0 "智能审稿增强" 开发进度(2026-03-07) -- [ ] Week 4 功能测试和 Bug 修复 +| 任务 | 状态 | 说明 | +|------|------|------| +| LLM 数据核查通道 | ✅ 已完成 | DataForensicsSkill 增加 LLM 验证,独立 60s 超时,规则验证兜底 | +| RVW_DATA_VALIDATION Prompt | ✅ 已完成 | PromptService 集成,运营管理端可配置 | +| 临床专业评估 Skill | ✅ 已完成 | ClinicalAssessmentSkill,基于 FINER 标准 | +| RVW_CLINICAL Prompt | ✅ 已完成 | PromptService 集成,运营管理端可配置 | +| SkillExecutor Promise.allSettled | ✅ 已完成 | 并行 Skill 故障隔离,单个崩溃不影响其他 | +| partial_completed 状态 | ✅ 已完成 | 新增任务状态 + error_details JSONB 字段 | +| reviewWorker 写入 errorDetails | ✅ 已完成 | 记录每个失败/超时 Skill 的名称和原因 | +| 前端 4 Tab 报告 | ✅ 已完成 | 稿约规范性/方法学/数据验证/临床评估 | +| 前端 partial_completed UI | ✅ 已完成 | 琥珀色警告横幅 + 列表"部分完成"标签 | +| Word 导出覆盖临床评估 | ✅ 已完成 | 导出报告包含临床专业评估章节 | + +### 后续版本(V3.1+) + +- [ ] 全面移除评分机制(只列问题,不打分) +- [ ] 单模块重试机制(partial_completed → 重试失败模块) - [ ] ANOVA 验证(多组比较) - [ ] 配对 T 检验验证 - [ ] 非参数检验(Mann-Whitney、Wilcoxon) @@ -429,7 +462,7 @@ Content-Type: multipart/form-data --- -**文档版本:** v5.1 -**最后更新:** 2026-02-18 -**当前状态:** 🚀 V2.0 "数据侦探" Week 3 完成,Skills 架构 + 统计验证 + 用户体验优化 -**下一步:** Week 4 功能测试和 Bug 修复 +**文档版本:** v6.0 +**最后更新:** 2026-03-07 +**当前状态:** 🚀 V3.0 "智能审稿增强" 完成(LLM数据核查 + 临床评估 + 稳定性增强) +**下一步:** V3.1 移除评分机制 + 单模块重试 diff --git a/docs/05-部署文档/03-待部署变更清单.md b/docs/05-部署文档/03-待部署变更清单.md index e56fff17..22ba1e3c 100644 --- a/docs/05-部署文档/03-待部署变更清单.md +++ b/docs/05-部署文档/03-待部署变更清单.md @@ -15,19 +15,32 @@ | # | 变更内容 | 迁移文件 | 优先级 | 备注 | |---|---------|---------|--------|------| -| — | *暂无* | | | | +| DB-1 | modules 表 seed 新增 ASL_SR 模块(系统综述项目) | `backend/scripts/seed-modules.js` | 高 | 部署后需执行 `node scripts/seed-modules.js`,并在运营管理端为目标用户/租户开通 | +| DB-2 | prompt_templates 表新增 RVW_DATA_VALIDATION + RVW_CLINICAL 两个 Prompt | `backend/scripts/migrate-rvw-prompts.ts` | 高 | 部署后需执行 `npx tsx scripts/migrate-rvw-prompts.ts`,运营管理端可配置修改 | +| DB-3 | ReviewTask 表新增 `error_details` JSONB 字段(存储 Skill 级失败详情) | `prisma/migrations/20260307_add_error_details_to_review_task/migration.sql` | 高 | 支持 partial_completed 状态,记录每个失败/超时 Skill 的名称和原因 | ### 后端变更 (Node.js) | # | 变更内容 | 涉及文件 | 需要操作 | 备注 | |---|---------|---------|---------|------| -| — | *暂无* | | | | +| BE-1 | Deep Research V2.0 新增历史列表 + 删除接口 + getTask 鉴权修复 | `deepResearchController.ts`, `routes/index.ts` | 重新构建镜像 | GET /research/v2/tasks, DELETE /research/tasks/:taskId, getTask 增加 userId 校验 | +| BE-2 | SR 相关路由(projects/literatures/screening/fulltext-screening/extraction/charting/meta-analysis)增加 `requireModule('ASL_SR')` 中间件 | `asl/routes/index.ts`, `extraction/routes/index.ts`, `charting/routes/index.ts`, `meta-analysis/routes/index.ts` | 重新构建镜像 | 需先完成 DB-1 seed,否则无 ASL_SR 模块会 403 | +| BE-3 | Unifuncs DeepSearch API 从 S2 升级至 S3(新增 `language: "zh"`) | `unifuncsSseClient.ts`, `unifuncsAsyncClient.ts` | 重新构建镜像 | 通过 `UNIFUNCS_MODEL` 环境变量控制,默认 s3,设为 s2 可降级 | +| BE-4 | RVW 数据验证增加 LLM 核查通道(DataForensicsSkill 增强) | `DataForensicsSkill.ts`, `prompt.fallbacks.ts` | 重新构建镜像 | 规则验证完成后批量调用 LLM 核查所有表格,失败时降级为纯规则验证 | +| BE-5 | RVW 新增临床专业评估维度(ClinicalAssessmentSkill) | `clinicalService.ts`(新), `ClinicalAssessmentSkill.ts`(新), `library/index.ts`, `profile.ts`, `utils.ts`, `reviewWorker.ts`, `reviewService.ts` | 重新构建镜像 | 新增 clinical Agent + Skill,存储在 contextData.clinicalReview | +| BE-6 | RVW 稳定性增强:SkillExecutor Promise.allSettled + partial_completed 状态 + errorDetails | `executor.ts`, `reviewWorker.ts`, `reviewService.ts`, `reviewController.ts`, `types/index.ts` | 重新构建镜像 | 并行 Skill 故障隔离,部分模块失败时仍返回成功模块结果,新增 `partial_completed` 任务状态 | +| BE-7 | DataForensicsSkill LLM 核查增加独立 60s 超时 | `DataForensicsSkill.ts` | 重新构建镜像 | LLM 核查超时不阻塞整体 Skill,graceful 降级为纯规则验证 | ### 前端变更 | # | 变更内容 | 涉及文件 | 需要操作 | 备注 | |---|---------|---------|---------|------| -| — | *暂无* | | | | +| FE-1 | ASL 左侧导航栏重构为互斥手风琴(Deep Research 历史记录 + SR 工具导航) | `ASLLayout.tsx`, `asl-sidebar.css`(新建), `DeepResearchPage.tsx`, `asl/index.tsx` | 重新构建镜像 | Panel A: 智能文献检索历史;Panel B: 系统综述项目(5 工具) | +| FE-2 | Deep Research 历史记录功能(API 客户端 + 类型定义 + URL 任务恢复) | `asl/api/index.ts`, `types/deepResearch.ts`, `DeepResearchPage.tsx`, `asl/index.tsx` | 重新构建镜像 | 新增 listDeepResearchTasks / deleteDeepResearchTask API;新增 /research/deep/:taskId 路由 | +| FE-3 | Panel B SR 工具导航权限控制(hasModule('ASL_SR')) | `ASLLayout.tsx`, `asl-sidebar.css` | 重新构建镜像 | 未开通时显示"请联系管理员开通";已开通显示 5 个 SR 工具导航项 | +| FE-4 | RVW 数据验证报告增加 LLM 核查结果展示 | `ForensicsReport.tsx`, `rvw/types/index.ts` | 重新构建镜像 | 总览展示完整 LLM 报告,每个表格卡片展开后显示对应 AI 核查结果(Markdown) | +| FE-5 | RVW 新增临床专业评估 Tab + Agent 选择项 | `ClinicalReport.tsx`(新), `AgentModal.tsx`, `TaskDetail.tsx`, `rvw/types/index.ts` | 重新构建镜像 | 共 4 个 Tab:稿约规范性/方法学/数据验证/临床评估;Word 导出包含临床评估章节 | +| FE-6 | RVW 前端支持 partial_completed 状态(部分完成) | `TaskDetail.tsx`, `TaskTable.tsx`, `rvw/types/index.ts` | 重新构建镜像 | 琥珀色警告横幅展示失败模块详情,列表页显示"部分完成"标签,支持查看已完成模块的报告 | ### Python 微服务变更 @@ -45,7 +58,7 @@ | # | 变更内容 | 服务 | 变量名 | 备注 | |---|---------|------|--------|------| -| — | *暂无* | | | | +| ENV-1 | Unifuncs 模型版本控制(可选,不配置则默认 s3) | nodejs-backend | `UNIFUNCS_MODEL=s3` | 降级时改为 `s2`,无需重新部署代码 | ### 基础设施变更 diff --git a/docs/05-部署文档/0305部署/01-部署完成总结.md b/docs/05-部署文档/0305部署/01-部署完成总结.md new file mode 100644 index 00000000..760e7eac --- /dev/null +++ b/docs/05-部署文档/0305部署/01-部署完成总结.md @@ -0,0 +1,197 @@ +# 2026年3月5日部署完成总结 + +> **部署日期**:2026-03-05 +> **部署范围**:数据库数据更新(1项) + Node.js后端 + 前端Nginx + R统计引擎待部署 +> **部署状态**:✅ 后端/前端已完成 +> **文档日期**:2026-03-05 + +--- + +## 部署成果一览 + +### 服务版本对比 + +| 服务 | 部署前 | 部署后 | 变更类型 | +|------|--------|--------|---------| +| PostgreSQL(RDS) | 86 表 | 86 表(数据更新) | modules 表 seed 更新 | +| Node.js后端 | v2.4 | **v2.6** | 登录踢人 + 权限体系 + SSA双通道 | +| 前端Nginx | v2.0 | **v2.3** | UI优化 + 权限适配 + 批量导入重构 | + +### 内网地址变更 + +| 服务 | 部署前地址 | 部署后地址 | 状态 | +|------|-----------|-----------|------| +| Node.js后端 | `172.17.197.32:3001` | `172.17.197.36:3001` | ✅ 已变更 | +| 前端Nginx | `172.17.197.32:80` | `172.17.173.104:80` | ✅ 已变更 | + +--- + +## 一、数据库更新 + +### 1.1 DB-1:modules 表数据更新 + +通过 `node scripts/seed-modules.js` 连接 RDS 外网执行(upsert 幂等操作): + +| 操作 | 模块代码 | 名称 | 说明 | +|------|---------|------|------| +| 新增 | `RM` | 研究管理 | 排序 9 | +| 新增 | `AIA_PROTOCOL` | 全流程研究方案制定 | 排序 100 | +| 更新 | `IIT` | CRA质控 | 原名 IIT Manager | + +执行结果:modules 表共 10 个模块,全部上线 ✅ + +### 1.2 未执行项 + +| 项目 | 原因 | +|------|------| +| DB-2:RVW Prompt 更新 | 用户指定不执行 | +| DB-3:SSA 双通道表结构 | 待后续部署 | + +--- + +## 二、Node.js后端更新(v2.4 → v2.6) + +### 2.1 主要变更(9 项) + +| 类别 | 变更内容 | +|------|---------| +| 登录安全 | 同一手机号登录踢人机制(JWT tokenVersion + 缓存校验) | +| 权限体系 | `/me/modules` API 尊重 user_modules 精细化配置 | +| 权限体系 | 用户模块配置校验放宽(模块代码存在即可,不限租户订阅) | +| 权限体系 | user_modules 独立生效(如 AIA_PROTOCOL 可单独配给用户) | +| 模块名称 | getModuleName 补充 RM、AIA_PROTOCOL、IIT→CRA质控 | +| RVW | 稿约 Prompt 源文件期刊名称修正 | +| Seed | 内部租户补充 RM、AIA_PROTOCOL 模块 | +| SSA | 双通道架构:Agent 模式 4 服务 + ChatHandler 分流 | +| 用户管理 | 批量导入增加 autoInheritModules + 模块校验 | + +### 2.2 镜像信息 + +| 项目 | 值 | +|------|---| +| ACR 仓库 | `backend-service` | +| 镜像版本 | v2.4 → **v2.6** | +| Digest | `sha256:17dc3b3b6171bad891b0d366a22e1b52d79db7fc9caccedf816a7feab4cea449` | +| 内网地址 | `http://172.17.197.36:3001` | + +--- + +## 三、前端Nginx更新(v2.0 → v2.3) + +### 3.1 主要变更(11 项) + +| 类别 | 变更内容 | +|------|---------| +| ASL | 隐藏数据源/年限/篇数 + 去掉研究方案生成/文献管理 + 默认进入智能文献检索 | +| AIA | 删除「已接入DeepSeek」和搜索框 + Protocol Agent 按权限动态显示 | +| AIA | 数据评价与预处理/智能统计分析链接修正 | +| 首页 | 重定向到 `/ai-qa`,不再显示模块卡片首页 | +| PKB | 创建知识库时隐藏科室选择,默认 General | +| 安全 | 被踢出时提示「账号已在其他设备登录」 | +| 运营端 | 用户模块权限弹窗显示所有模块(含未订阅标注) | +| 运营端 | 批量导入用户重构为 4 步流程 + 自动继承租户模块 | + +### 3.2 镜像信息 + +| 项目 | 值 | +|------|---| +| ACR 仓库 | `ai-clinical_frontend-nginx` | +| 镜像版本 | v2.0 → **v2.3** | +| Digest | `sha256:db031053d8ac50d8f2ce39a8406534743d974a5506b7d4af5a944dd145ce8589` | +| 内网地址 | `http://172.17.173.104:80` | + +--- + +## 四、环境变量联动更新 + +| 服务 | 环境变量 | 旧值 | 新值 | +|------|---------|------|------| +| frontend-nginx-service | `BACKEND_SERVICE_HOST` | `172.17.197.32` | `172.17.197.36` | + +> CLB 负载均衡器由阿里云自动更新,无需手动操作。 + +--- + +## 五、待后续部署项 + +| 项目 | 说明 | +|------|------| +| DB-3 | SSA 双通道表结构(ssa_sessions + ssa_agent_executions) | +| R-1 | R 统计引擎新增 execute-code 端点(需重构 R 镜像) | + +--- + +## 六、当前系统配置速查 + +### 服务内网地址 + +``` +R统计引擎: http://172.17.173.101:8080 (不变) +Python: http://172.17.173.102:8000 (不变) +后端: http://172.17.197.36:3001 (更新) +前端: http://172.17.173.104:80 (更新) +``` + +### ACR 镜像版本 + +| 仓库 | 版本 | +|------|-----| +| `ssa-r-statistics` | v1.0.1 | +| `python-extraction` | v1.2 | +| `backend-service` | **v2.6** | +| `ai-clinical_frontend-nginx` | **v2.3** | + +### 公网访问 + +``` +CLB: http://8.140.53.236/ +域名: https://iit.xunzhengyixue.com/ +``` + +--- + +--- + +## 七、二次热修部署(同日) + +### 7.1 触发原因 + +- SSA 智能统计分析上传文件报错:`The column execution_mode does not exist in the current database` +- 前端/后端其他 bug 修复(基于测试反馈) + +### 7.2 数据库热修 + +| 操作 | 内容 | 状态 | +|------|------|------| +| ALTER TABLE | `ssa_sessions` 新增 `execution_mode` 列(默认 `'qper'`) | ✅ | +| CREATE TABLE | `ssa_agent_executions` 表 + 索引 + 外键 | ✅ | +| INSERT | Prisma 迁移记录同步注册 | ✅ | + +### 7.3 镜像重构与部署 + +| 服务 | 版本变化 | 镜像 Digest | IP 变化 | +|------|---------|-------------|---------| +| Node.js 后端 | v2.6 → **v2.7** | `sha256:bfb7d3e6ce39...` | `172.17.197.36` → `172.17.197.37` | +| 前端 Nginx | v2.3 → **v2.4** | `sha256:03fab06fb1a2...` | `172.17.173.104` → `172.17.173.105` | + +### 7.4 最终系统配置 + +``` +后端: http://172.17.197.37:3001 +前端: http://172.17.173.105:80 +R统计引擎: http://172.17.173.101:8080 +Python: http://172.17.173.102:8000 +``` + +| ACR 仓库 | 最终版本 | +|----------|---------| +| `backend-service` | **v2.7** | +| `ai-clinical_frontend-nginx` | **v2.4** | +| `ssa-r-statistics` | v1.0.1 | +| `python-extraction` | v1.2 | + +--- + +> **文档版本**:v1.1 +> **最后更新**:2026-03-05 +> **维护人员**:开发团队 diff --git a/frontend-v2/src/modules/asl/api/index.ts b/frontend-v2/src/modules/asl/api/index.ts index feeff0ce..9869769e 100644 --- a/frontend-v2/src/modules/asl/api/index.ts +++ b/frontend-v2/src/modules/asl/api/index.ts @@ -485,6 +485,7 @@ import type { GenerateRequirementRequest, GenerateRequirementResponse, DeepResearchTask, + DeepResearchTaskSummary, } from '../types/deepResearch'; /** @@ -519,6 +520,20 @@ export async function executeDeepResearchTask( }); } +/** + * 获取 V2.0 任务历史列表(最近 100 条,不含 draft) + */ +export async function listDeepResearchTasks(): Promise> { + return request('/research/v2/tasks'); +} + +/** + * 删除 V2.0 检索任务 + */ +export async function deleteDeepResearchTask(taskId: string): Promise> { + return request(`/research/tasks/${taskId}`, { method: 'DELETE' }); +} + /** * 获取 V2.0 任务详情(状态 + 日志 + 结果) */ @@ -714,6 +729,8 @@ export const aslApi = { getDeepResearchDataSources, generateRequirement, executeDeepResearchTask, + listDeepResearchTasks, + deleteDeepResearchTask, getDeepResearchTask, // 工具 3:全文智能提取 diff --git a/frontend-v2/src/modules/asl/components/ASLLayout.tsx b/frontend-v2/src/modules/asl/components/ASLLayout.tsx index 8668d224..845bf1d8 100644 --- a/frontend-v2/src/modules/asl/components/ASLLayout.tsx +++ b/frontend-v2/src/modules/asl/components/ASLLayout.tsx @@ -1,160 +1,288 @@ /** - * ASL模块布局组件 - * - * 左侧导航栏 + 右侧内容区 - * 参考原型:AI智能文献-标题摘要初筛原型.html + * ASL 模块布局 — 互斥手风琴侧边栏 + * + * 参考 v6.html 设计 + Protocol Agent (AIA) 模块 UI 风格 + * - 面板 A:智能文献检索(历史列表 + 新建检索) + * - 面板 B:系统综述项目(需要 ASL_SR 模块权限) + * - 两个面板 Header 始终可见,内容区互斥展开 */ -import { Layout, Menu } from 'antd'; +import { useState, useCallback, useMemo, createContext, useContext } from 'react'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Popconfirm, message } from 'antd'; import { - SearchOutlined, - FilterOutlined, - DatabaseOutlined, - BarChartOutlined, - SettingOutlined, - CheckSquareOutlined, - UnorderedListOutlined, - ApartmentOutlined, -} from '@ant-design/icons'; -import type { MenuProps } from 'antd'; + Sparkles, + ChevronDown, + Plus, + History, + MessageSquare, + FolderKanban, + Trash2, + ListFilter, + FileSearch, + FileText, + BarChart3, + TrendingUp, + Lock, +} from 'lucide-react'; +import { useAuth } from '../../../framework/auth/AuthContext'; +import { aslApi } from '../api'; +import type { DeepResearchTaskSummary } from '../types/deepResearch'; +import '../styles/asl-sidebar.css'; -const { Sider, Content } = Layout; +// ─── Context ──────────────────────────────────── -type MenuItem = Required['items'][number]; +interface ASLLayoutContextValue { + refreshHistory: () => void; +} + +const ASLLayoutContext = createContext({ + refreshHistory: () => {}, +}); + +export function useASLLayout() { + return useContext(ASLLayoutContext); +} + +// ─── 日期分组 ─────────────────────────────────── + +function groupByDate(tasks: DeepResearchTaskSummary[]) { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today.getTime() - 86400000); + const weekAgo = new Date(today.getTime() - 7 * 86400000); + + const groups: { label: string; items: DeepResearchTaskSummary[] }[] = [ + { label: '今天', items: [] }, + { label: '昨天', items: [] }, + { label: '最近 7 天', items: [] }, + { label: '更早', items: [] }, + ]; + + for (const task of tasks) { + const d = new Date(task.createdAt); + if (d >= today) groups[0].items.push(task); + else if (d >= yesterday) groups[1].items.push(task); + else if (d >= weekAgo) groups[2].items.push(task); + else groups[3].items.push(task); + } + + return groups.filter((g) => g.items.length > 0); +} + +// ─── 主组件 ───────────────────────────────────── + +type PanelType = 'SEARCH' | 'PROJECT'; + +const SR_NAV_ITEMS = [ + { label: '标题摘要初筛', path: '/literature/screening/title/settings', icon: ListFilter, matchPrefix: '/literature/screening/title' }, + { label: '全文复筛', path: '/literature/screening/fulltext/settings', icon: FileSearch, matchPrefix: '/literature/screening/fulltext' }, + { label: '全文智能提取', path: '/literature/extraction/setup', icon: FileText, matchPrefix: '/literature/extraction' }, + { label: 'SR 图表生成器', path: '/literature/charting', icon: BarChart3, matchPrefix: '/literature/charting' }, + { label: 'Meta 分析引擎', path: '/literature/meta-analysis', icon: TrendingUp, matchPrefix: '/literature/meta-analysis' }, +]; const ASLLayout = () => { const navigate = useNavigate(); const location = useLocation(); + const queryClient = useQueryClient(); + const { hasModule } = useAuth(); + const hasSR = hasModule('ASL_SR'); - // 菜单项配置 - const menuItems: MenuItem[] = [ - { - key: '/literature/research/deep', - icon: , - label: '1. 智能文献检索', - }, - { - key: 'title-screening', - icon: , - label: '2. 标题摘要初筛', - children: [ - { - key: '/literature/screening/title/settings', - icon: , - label: '设置与启动', - }, - { - key: '/literature/screening/title/workbench', - icon: , - label: '审核工作台', - }, - { - key: '/literature/screening/title/results', - icon: , - label: '初筛结果', - }, - ], - }, - { - key: 'extraction', - icon: , - label: '3. 全文智能提取', - children: [ - { - key: '/literature/extraction/setup', - icon: , - label: '配置与启动', - }, - { - key: '/literature/extraction/workbench', - icon: , - label: '审核工作台', - }, - ], - }, - { - key: '/literature/charting', - icon: , - label: '4. SR 图表生成器', - }, - { - key: '/literature/meta-analysis', - icon: , - label: '5. Meta 分析引擎', - }, - ]; + const [expandedPanel, setExpandedPanel] = useState('SEARCH'); + const [deletingId, setDeletingId] = useState(null); - // 处理菜单点击 - const handleMenuClick: MenuProps['onClick'] = ({ key }) => { - if (key.startsWith('/')) { - navigate(key); - } - }; + const currentTaskId = useMemo(() => { + const match = location.pathname.match(/\/research\/deep\/([^/]+)/); + return match ? match[1] : null; + }, [location.pathname]); - // 获取当前选中的菜单项和展开的子菜单 - const currentPath = location.pathname; - const selectedKeys = [currentPath]; - - // 根据当前路径确定展开的菜单 - const getOpenKeys = () => { - if (currentPath.includes('screening/title')) return ['title-screening']; - if (currentPath.includes('/extraction')) return ['extraction']; - if (currentPath.includes('/charting')) return []; - if (currentPath.includes('/meta-analysis')) return []; - return []; - }; - const openKeys = getOpenKeys(); + const { data: historyResp } = useQuery({ + queryKey: ['deep-research-history'], + queryFn: () => aslApi.listDeepResearchTasks(), + staleTime: 30_000, + }); + const historyTasks = historyResp?.data ?? []; + const groupedHistory = useMemo(() => groupByDate(historyTasks), [historyTasks]); - // 智能文献检索页面使用全屏布局(无左侧导航栏装饰) - const isResearchPage = currentPath.includes('/research/'); + const refreshHistory = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ['deep-research-history'] }); + }, [queryClient]); + + const togglePanel = useCallback((panel: PanelType) => { + setExpandedPanel((prev) => + prev === panel + ? panel === 'SEARCH' ? 'PROJECT' : 'SEARCH' + : panel + ); + }, []); + + const selectTask = useCallback( + (taskId: string) => navigate(`/literature/research/deep/${taskId}`), + [navigate] + ); + + const handleNewSearch = useCallback(() => { + navigate('/literature/research/deep'); + }, [navigate]); + + const handleDelete = useCallback( + async (taskId: string) => { + setDeletingId(taskId); + try { + await aslApi.deleteDeepResearchTask(taskId); + message.success('已删除'); + refreshHistory(); + if (currentTaskId === taskId) { + navigate('/literature/research/deep'); + } + } catch (err: any) { + message.error(err.message || '删除失败'); + } finally { + setDeletingId(null); + } + }, + [currentTaskId, navigate, refreshHistory] + ); + + const isSearchExpanded = expandedPanel === 'SEARCH'; + const contextValue = useMemo(() => ({ refreshHistory }), [refreshHistory]); return ( - - {/* 左侧导航栏 */} - -
-

- - AI智能文献 -

-

- 标题摘要初筛 MVP -

-
- - - + +
+ {/* ── 侧边栏 ── */} +
- {/* 右侧内容区 */} - - + {/* ── 面板 A Header ── */} +
togglePanel('SEARCH')} + > +
+ + 智能文献检索 +
+ +
+ + {/* ── 面板 A Content ── */} +
+ + +
+ 最近检索历史 + +
+ +
+ {historyTasks.length === 0 ? ( +
暂无检索记录
+ ) : ( + groupedHistory.map((group) => ( +
+
{group.label}
+ {group.items.map((task) => { + const isActive = currentTaskId === task.id; + return ( +
selectTask(task.id)} + > + + + {task.query} + { + e?.stopPropagation(); + handleDelete(task.id); + }} + onCancel={(e) => e?.stopPropagation()} + okText="删除" + cancelText="取消" + okButtonProps={{ danger: true }} + > + + +
+ ); + })} +
+ )) + )} +
+
+ + {/* ── 分割线 ── */} +
+ + {/* ── 面板 B Header ── */} +
togglePanel('PROJECT')} + > +
+ + 系统综述项目 (SR) +
+ +
+ + {/* ── 面板 B Content ── */} +
+ {hasSR ? ( +
+ {SR_NAV_ITEMS.map((item) => { + const isActive = location.pathname.startsWith(item.matchPrefix); + const Icon = item.icon; + return ( +
navigate(item.path)} + > + + {item.label} +
+ ); + })} +
+ ) : ( +
+ +

请联系管理员开通系统综述功能

+
+ )} +
+
+ + {/* ── 右侧内容区 ── */} +
- - - +
+
+ ); }; export default ASLLayout; - - - - - - - - - diff --git a/frontend-v2/src/modules/asl/components/deep-research/ResultsView.tsx b/frontend-v2/src/modules/asl/components/deep-research/ResultsView.tsx index 846c103b..257b4735 100644 --- a/frontend-v2/src/modules/asl/components/deep-research/ResultsView.tsx +++ b/frontend-v2/src/modules/asl/components/deep-research/ResultsView.tsx @@ -24,9 +24,10 @@ const { Text } = Typography; interface ResultsViewProps { task: DeepResearchTask; onNewSearch: () => void; + hideBanner?: boolean; } -const ResultsView: React.FC = ({ task, onNewSearch }) => { +const ResultsView: React.FC = ({ task, onNewSearch, hideBanner }) => { const { synthesisReport, resultList, resultCount, query, completedAt, taskId } = task; const [exporting, setExporting] = useState(false); @@ -129,28 +130,30 @@ const ResultsView: React.FC = ({ task, onNewSearch }) => { return (
{/* 完成横幅 */} - -
-
- -
- Deep Research 完成 - - 「{query}」 — 找到 {resultCount || 0} 篇文献 - {completedAt && ` · ${new Date(completedAt).toLocaleString()}`} - + {!hideBanner && ( + +
+
+ +
+ Deep Research 完成 + + 「{query}」 — 找到 {resultCount || 0} 篇文献 + {completedAt && ` · ${new Date(completedAt).toLocaleString()}`} + +
+
+
+ +
-
- - -
-
- + + )} {/* AI 综合报告 — Markdown 渲染 */} {synthesisReport && ( diff --git a/frontend-v2/src/modules/asl/components/deep-research/StrategyConfirm.tsx b/frontend-v2/src/modules/asl/components/deep-research/StrategyConfirm.tsx index cc659328..341ca331 100644 --- a/frontend-v2/src/modules/asl/components/deep-research/StrategyConfirm.tsx +++ b/frontend-v2/src/modules/asl/components/deep-research/StrategyConfirm.tsx @@ -24,6 +24,7 @@ interface StrategyConfirmProps { intentSummary: IntentSummary | null; onConfirm: (confirmedRequirement: string) => void; collapsed?: boolean; + readOnly?: boolean; } const StrategyConfirm: React.FC = ({ @@ -31,11 +32,13 @@ const StrategyConfirm: React.FC = ({ intentSummary, onConfirm, collapsed, + readOnly, }) => { const [editedRequirement, setEditedRequirement] = useState(generatedRequirement); const [saved, setSaved] = useState(false); + const [expanded, setExpanded] = useState(false); - if (collapsed) { + if (collapsed && !readOnly) { return (
@@ -46,6 +49,64 @@ const StrategyConfirm: React.FC = ({ ); } + if (readOnly) { + return ( + setExpanded(prev => !prev)} + > +
+ + 检索需求书 +
+ + {expanded ? '收起' : '展开详情'} + +
+ } + > + {intentSummary && ( +
+
+ + {intentSummary.objective} +
+
+ P 人群 {intentSummary.population || '—'} + I 干预 {intentSummary.intervention || '—'} + C 对照 {intentSummary.comparison || '—'} + O 结局 {intentSummary.outcome || '—'} +
+ {intentSummary.meshTerms && intentSummary.meshTerms.length > 0 && ( +
+ + {intentSummary.meshTerms.map((term, i) => ( + {term} + ))} +
+ )} + {intentSummary.studyDesign && intentSummary.studyDesign.length > 0 && ( +
+ {intentSummary.studyDesign.map((s, i) => ( + {s} + ))} +
+ )} +
+ )} + {expanded && generatedRequirement && ( +
+ {generatedRequirement} +
+ )} +
+ ); + } + const handleSave = () => { setSaved(true); message.success('检索指令已保存'); diff --git a/frontend-v2/src/modules/asl/index.tsx b/frontend-v2/src/modules/asl/index.tsx index b18485ce..5d100032 100644 --- a/frontend-v2/src/modules/asl/index.tsx +++ b/frontend-v2/src/modules/asl/index.tsx @@ -71,6 +71,7 @@ const ASLModule = () => { {/* Deep Research V2.0 */} } /> + } /> {/* 标题摘要初筛 */} diff --git a/frontend-v2/src/modules/asl/pages/DeepResearchPage.tsx b/frontend-v2/src/modules/asl/pages/DeepResearchPage.tsx index 8c1b7047..d81a97f9 100644 --- a/frontend-v2/src/modules/asl/pages/DeepResearchPage.tsx +++ b/frontend-v2/src/modules/asl/pages/DeepResearchPage.tsx @@ -2,13 +2,19 @@ * Deep Research V2.0 主页面 — 瀑布流布局 * * Phase 0: Landing(全屏居中搜索) - * Phase 1+: 配置 → 策略 → 执行 → 结果,依次累积展示 + * Phase 1+: 配置 -> 策略 -> 执行 -> 结果,依次累积展示 + * + * 支持从 URL 参数 :taskId 恢复历史任务。 */ import { useState, useCallback, useRef, useEffect } from 'react'; -import { message } from 'antd'; +import { useParams, useNavigate } from 'react-router-dom'; +import { Card, Button, Typography, message } from 'antd'; +import { CheckCircleFilled, DownloadOutlined, PlusOutlined, ClockCircleOutlined, CloseCircleFilled } from '@ant-design/icons'; +import { getAccessToken } from '../../../framework/auth/api'; import { aslApi } from '../api'; import { useDeepResearchTask } from '../hooks/useDeepResearchTask'; +import { useASLLayout } from '../components/ASLLayout'; import LandingView from '../components/deep-research/LandingView'; import SetupPanel from '../components/deep-research/SetupPanel'; import StrategyConfirm from '../components/deep-research/StrategyConfirm'; @@ -19,28 +25,74 @@ import type { IntentSummary, GenerateRequirementResponse } from '../types/deepRe type Phase = 0 | 1 | 2 | 3 | 4; const DeepResearchPage = () => { + const { taskId: urlTaskId } = useParams<{ taskId?: string }>(); + const navigate = useNavigate(); + const { refreshHistory } = useASLLayout(); + const [phase, setPhase] = useState(0); const [query, setQuery] = useState(''); const [taskId, setTaskId] = useState(null); const [generatedRequirement, setGeneratedRequirement] = useState(''); const [intentSummary, setIntentSummary] = useState(null); const [generating, setGenerating] = useState(false); + const [restoredFromUrl, setRestoredFromUrl] = useState(false); const strategyRef = useRef(null); const terminalRef = useRef(null); const resultsRef = useRef(null); + // 轮询 hook(phase >= 3 或从 URL 恢复的任务) + const shouldPoll = phase >= 3 || (restoredFromUrl && !!taskId); const { task, isRunning, isCompleted, isFailed } = useDeepResearchTask({ taskId, - enabled: phase >= 3, + enabled: shouldPoll, }); + // 从 URL :taskId 恢复历史任务,或 URL 失去 taskId 时重置 useEffect(() => { - if (isCompleted && phase === 3) { + if (urlTaskId && urlTaskId !== taskId) { + setTaskId(urlTaskId); + setRestoredFromUrl(true); + } else if (!urlTaskId && taskId) { + setPhase(0); + setTaskId(null); + setQuery(''); + setGeneratedRequirement(''); + setIntentSummary(null); + setRestoredFromUrl(false); + } + }, [urlTaskId]); // eslint-disable-line react-hooks/exhaustive-deps + + // 恢复的任务加载完成后设置正确的 phase + useEffect(() => { + if (!restoredFromUrl || !task) return; + + setQuery(task.query || ''); + if (task.confirmedRequirement) { + setGeneratedRequirement(task.confirmedRequirement); + } + if (task.aiIntentSummary) { + setIntentSummary(task.aiIntentSummary); + } + + if (task.status === 'completed') { + setPhase(4); + } else if (task.status === 'running' || task.status === 'pending') { + setPhase(3); + } else if (task.status === 'failed') { + setPhase(4); + } else if (task.status === 'draft') { + setPhase(2); + } + }, [restoredFromUrl, task]); + + // 正常流程中:执行完成自动进入结果页 + useEffect(() => { + if (isCompleted && phase === 3 && !restoredFromUrl) { setPhase(4); setTimeout(() => resultsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 150); } - }, [isCompleted, phase]); + }, [isCompleted, phase, restoredFromUrl]); const scrollTo = (ref: React.RefObject) => { setTimeout(() => ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 150); @@ -52,6 +104,7 @@ const DeepResearchPage = () => { setGeneratedRequirement(''); setIntentSummary(null); setTaskId(null); + setRestoredFromUrl(false); }, []); const handleSetupSubmit = useCallback(async ( @@ -86,10 +139,13 @@ const DeepResearchPage = () => { setPhase(3); scrollTo(terminalRef); message.success('Deep Research 已启动'); + // 任务进入 pending 后刷新侧边栏历史列表 + navigate(`/literature/research/deep/${taskId}`, { replace: true }); + refreshHistory(); } catch (err: any) { message.error(err.message || '启动失败'); } - }, [taskId]); + }, [taskId, navigate, refreshHistory]); const handleNewSearch = useCallback(() => { setPhase(0); @@ -97,9 +153,69 @@ const DeepResearchPage = () => { setTaskId(null); setGeneratedRequirement(''); setIntentSummary(null); + setRestoredFromUrl(false); + navigate('/literature/research/deep', { replace: true }); window.scrollTo({ top: 0, behavior: 'smooth' }); - }, []); + }, [navigate]); + // ── 从 URL 恢复的任务:直接展示结果或执行中状态 ── + if (restoredFromUrl && task) { + const isFinished = task.status === 'completed' || task.status === 'failed'; + + return ( +
+
+ {/* 顶部横幅:完成/失败状态 + 导出按钮 */} + {isFinished && ( + + )} + {!isFinished && ( + +
+ + 「{task.query}」 — 深度检索执行中... +
+
+ )} + + {/* 检索需求书(PICOS + 完整指令) */} + {task.confirmedRequirement && task.aiIntentSummary && ( +
+ {}} + readOnly + /> +
+ )} + + {/* 执行日志 */} +
+ +
+ + {/* 结果区(报告 + 文献表格),横幅已在顶部展示 */} + {isFinished && ( +
+ +
+ )} +
+
+ ); + } + + // 恢复中但 task 还未加载 — loading + if (restoredFromUrl && !task) { + return ( +
+
加载中...
+
+ ); + } + + // ── 正常新建流程 ── if (phase === 0) { return (
@@ -158,4 +274,79 @@ const DeepResearchPage = () => { ); }; +// ── 历史记录顶部横幅(完成/失败 + 导出) ── + +const { Text } = Typography; + +function HistoryBanner({ task, onNewSearch }: { task: any; onNewSearch: () => void }) { + const [exporting, setExporting] = useState(false); + const isCompleted = task.status === 'completed'; + + const handleExportWord = async () => { + setExporting(true); + try { + const token = getAccessToken(); + const res = await fetch(`/api/v1/asl/research/tasks/${task.taskId}/export-word`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const blob = await res.blob(); + const disposition = res.headers.get('Content-Disposition') || ''; + const filenameMatch = disposition.match(/filename\*?=(?:UTF-8'')?([^;]+)/); + const filename = filenameMatch ? decodeURIComponent(filenameMatch[1]) : 'DeepResearch.docx'; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + message.success('导出成功'); + } catch (err: any) { + message.error(err.message || '导出失败'); + } finally { + setExporting(false); + } + }; + + if (!isCompleted) { + return ( + +
+
+ +
+ 检索失败 + 「{task.query}」 +
+
+ +
+
+ ); + } + + return ( + +
+
+ +
+ Deep Research 完成 + + 「{task.query}」 — {task.resultCount || 0} 篇文献 + {task.completedAt && ` · ${new Date(task.completedAt).toLocaleString()}`} + +
+
+
+ + +
+
+
+ ); +} + export default DeepResearchPage; diff --git a/frontend-v2/src/modules/asl/styles/asl-sidebar.css b/frontend-v2/src/modules/asl/styles/asl-sidebar.css new file mode 100644 index 00000000..6674b248 --- /dev/null +++ b/frontend-v2/src/modules/asl/styles/asl-sidebar.css @@ -0,0 +1,373 @@ +/** + * ASL 互斥手风琴侧边栏样式 + * 对齐 Protocol Agent (AIA) 模块风格 + */ + +/* ── 侧边栏容器 ── */ +.asl-sidebar { + width: 280px; + min-width: 280px; + display: flex; + flex-direction: column; + background: #F9FAFB; + border-right: 1px solid #E5E7EB; + overflow: hidden; +} + +/* ── 面板 Header ── */ +.asl-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + cursor: pointer; + transition: background 0.2s; + user-select: none; + flex-shrink: 0; + border-bottom: 1px solid #E5E7EB; +} + +.asl-panel-header:hover { + background: #F3F4F6; +} + +.asl-panel-header-left { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: #374151; + transition: color 0.2s; +} + +.asl-panel-header-left.active { + color: #6366F1; +} + +.asl-panel-header-left .header-icon { + color: #9CA3AF; + transition: color 0.2s; +} + +.asl-panel-header-left.active .header-icon { + color: #6366F1; +} + +.asl-panel-chevron { + color: #9CA3AF; + transition: transform 0.3s ease; +} + +.asl-panel-chevron.expanded { + transform: rotate(180deg); +} + +/* ── 面板内容区(可展开/折叠) ── */ +.asl-panel-content { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.asl-panel-content.collapsed { + flex: 0; + min-height: 0; + max-height: 0; + overflow: hidden; +} + +/* ── 新建按钮 ── */ +.asl-new-btn { + margin: 8px 12px; + padding: 8px 12px; + background: #6366F1; + color: white; + border: none; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + transition: all 0.2s; + flex-shrink: 0; +} + +.asl-new-btn:hover { + background: #4F46E5; +} + +/* ── 列表区域 ── */ +.asl-list-section-label { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 16px; + font-size: 11px; + font-weight: 600; + color: #9CA3AF; + text-transform: uppercase; + letter-spacing: 0.05em; + flex-shrink: 0; +} + +.asl-history-list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 4px 8px; + scrollbar-width: thin; + scrollbar-color: #CBD5E1 transparent; +} + +.asl-history-list::-webkit-scrollbar { + width: 6px; +} + +.asl-history-list::-webkit-scrollbar-track { + background: transparent; +} + +.asl-history-list::-webkit-scrollbar-thumb { + background: #CBD5E1; + border-radius: 3px; +} + +.asl-history-list::-webkit-scrollbar-thumb:hover { + background: #94A3B8; +} + +/* ── 日期分组标签 ── */ +.asl-date-group-label { + padding: 8px 12px 4px; + font-size: 11px; + font-weight: 500; + color: #9CA3AF; +} + +/* ── 历史列表项(对齐 Protocol Agent .conv-item) ── */ +.asl-conv-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + color: #6B7280; + font-size: 13px; + position: relative; +} + +.asl-conv-item:hover { + background: #F3F4F6; +} + +.asl-conv-item.active { + background: #EEF2FF; + color: #6366F1; +} + +.asl-conv-item .conv-icon { + flex-shrink: 0; + color: #9CA3AF; +} + +.asl-conv-item.active .conv-icon { + color: #6366F1; +} + +.asl-conv-title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.asl-conv-status { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.asl-conv-status.completed { + background: #10B981; +} + +.asl-conv-status.running { + background: #F59E0B; + animation: pulse-dot 2s ease-in-out infinite; +} + +.asl-conv-status.failed { + background: #EF4444; +} + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* ── 删除按钮(对齐 Protocol Agent .conv-delete) ── */ +.asl-conv-delete { + opacity: 0; + background: none; + border: none; + padding: 4px; + border-radius: 4px; + cursor: pointer; + color: #EF4444; + transition: all 0.2s; + flex-shrink: 0; +} + +.asl-conv-item:hover .asl-conv-delete { + opacity: 1; +} + +.asl-conv-delete:hover { + background: #FEE2E2; +} + +/* ── 空状态 ── */ +.asl-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px 16px; + text-align: center; + color: #9CA3AF; + font-size: 13px; + flex: 1; +} + +/* ── 面板 B 占位 ── */ +.asl-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px 16px; + text-align: center; + flex: 1; +} + +.asl-placeholder-icon { + color: #D1D5DB; + margin-bottom: 12px; +} + +.asl-placeholder-title { + font-size: 14px; + font-weight: 500; + color: #6B7280; + margin: 0 0 4px; +} + +.asl-placeholder-desc { + font-size: 12px; + color: #9CA3AF; + margin: 0; +} + +.asl-disabled-btn { + margin: 0 16px 16px; + padding: 8px; + border: 1px dashed #D1D5DB; + background: white; + color: #9CA3AF; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + font-size: 13px; + cursor: not-allowed; + flex-shrink: 0; +} + +/* ── 分割线 ── */ +.asl-divider { + height: 1px; + background: #E5E7EB; + flex-shrink: 0; +} + +/* ── SR 工具导航项 ── */ +.asl-nav-list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 4px 8px; + scrollbar-width: thin; + scrollbar-color: #CBD5E1 transparent; +} + +.asl-nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + color: #6B7280; + font-size: 13px; + text-decoration: none; +} + +.asl-nav-item:hover { + background: #F3F4F6; + color: #374151; +} + +.asl-nav-item.active { + background: #EEF2FF; + color: #6366F1; +} + +.asl-nav-item .nav-icon { + flex-shrink: 0; + color: #9CA3AF; + transition: color 0.2s; +} + +.asl-nav-item.active .nav-icon { + color: #6366F1; +} + +.asl-nav-item-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ── SR 未开通提示 ── */ +.asl-sr-locked { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px 16px; + text-align: center; + flex: 1; +} + +.asl-sr-locked-icon { + color: #D1D5DB; + margin-bottom: 12px; +} + +.asl-sr-locked-text { + font-size: 13px; + color: #9CA3AF; + margin: 0; +} diff --git a/frontend-v2/src/modules/asl/types/deepResearch.ts b/frontend-v2/src/modules/asl/types/deepResearch.ts index bc857cd8..650cb806 100644 --- a/frontend-v2/src/modules/asl/types/deepResearch.ts +++ b/frontend-v2/src/modules/asl/types/deepResearch.ts @@ -74,3 +74,12 @@ export interface GenerateRequirementResponse { } export type DeepResearchStep = 'landing' | 'setup' | 'strategy' | 'executing' | 'results'; + +export interface DeepResearchTaskSummary { + id: string; + query: string; + status: 'pending' | 'running' | 'completed' | 'failed'; + resultCount: number | null; + createdAt: string; + completedAt: string | null; +} diff --git a/frontend-v2/src/modules/rvw/components/AgentModal.tsx b/frontend-v2/src/modules/rvw/components/AgentModal.tsx index a85d19c1..590a9911 100644 --- a/frontend-v2/src/modules/rvw/components/AgentModal.tsx +++ b/frontend-v2/src/modules/rvw/components/AgentModal.tsx @@ -99,6 +99,29 @@ export default function AgentModal({ visible, taskCount, onClose, onConfirm, isS
深度 + + {/* 临床专业评估智能体 */} +
{/* 底部按钮 */} diff --git a/frontend-v2/src/modules/rvw/components/ClinicalReport.tsx b/frontend-v2/src/modules/rvw/components/ClinicalReport.tsx new file mode 100644 index 00000000..073c8fc6 --- /dev/null +++ b/frontend-v2/src/modules/rvw/components/ClinicalReport.tsx @@ -0,0 +1,181 @@ +/** + * 临床专业评估报告组件 + * 将 LLM 返回的 Markdown 报告按章节拆分为结构化卡片 + * 展现风格与 EditorialReport / MethodologyReport 统一 + */ +import { useState } from 'react'; +import { Stethoscope, ChevronDown, ChevronUp, AlertTriangle, CheckCircle, Lightbulb, TrendingUp } from 'lucide-react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import type { ClinicalReviewResult } from '../types'; + +interface ClinicalReportProps { + data: ClinicalReviewResult; +} + +interface ReportSection { + title: string; + content: string; + level: number; +} + +function parseSections(markdown: string): { summary: string; sections: ReportSection[] } { + const lines = markdown.split('\n'); + const sections: ReportSection[] = []; + let summary = ''; + let currentSection: ReportSection | null = null; + let contentLines: string[] = []; + let summaryCollected = false; + + for (const line of lines) { + const h2Match = line.match(/^##\s+(.+)/); + const h3Match = line.match(/^###\s+(.+)/); + + if (h2Match || h3Match) { + if (currentSection) { + currentSection.content = contentLines.join('\n').trim(); + if (currentSection.content) sections.push(currentSection); + } else if (!summaryCollected) { + summary = contentLines.join('\n').trim(); + summaryCollected = true; + } + currentSection = { + title: (h2Match ? h2Match[1] : h3Match![1]).trim(), + content: '', + level: h2Match ? 2 : 3, + }; + contentLines = []; + } else if (line.match(/^#\s+/)) { + if (currentSection) { + currentSection.content = contentLines.join('\n').trim(); + if (currentSection.content) sections.push(currentSection); + currentSection = null; + } + contentLines = []; + } else { + contentLines.push(line); + } + } + + if (currentSection) { + currentSection.content = contentLines.join('\n').trim(); + if (currentSection.content) sections.push(currentSection); + } else if (!summaryCollected) { + summary = contentLines.join('\n').trim(); + } + + return { summary, sections }; +} + +function getSectionIcon(title: string) { + const t = title.toLowerCase(); + if (t.includes('创新') || t.includes('interesting')) return { color: 'text-purple-500', bg: 'bg-purple-50', border: 'border-purple-100' }; + if (t.includes('临床价值') || t.includes('relevant') || t.includes('相关')) return { color: 'text-blue-500', bg: 'bg-blue-50', border: 'border-blue-100' }; + if (t.includes('科学') || t.includes('假设')) return { color: 'text-indigo-500', bg: 'bg-indigo-50', border: 'border-indigo-100' }; + if (t.includes('可行') || t.includes('feasib') || t.includes('伦理')) return { color: 'text-green-500', bg: 'bg-green-50', border: 'border-green-100' }; + if (t.includes('优化') || t.includes('建议') || t.includes('结论')) return { color: 'text-amber-500', bg: 'bg-amber-50', border: 'border-amber-100' }; + if (t.includes('明确') || t.includes('pico') || t.includes('问题')) return { color: 'text-pink-500', bg: 'bg-pink-50', border: 'border-pink-100' }; + return { color: 'text-slate-500', bg: 'bg-slate-50', border: 'border-slate-100' }; +} + +export default function ClinicalReport({ data }: ClinicalReportProps) { + const { summary, sections } = parseSections(data.report); + const [collapsedSections, setCollapsedSections] = useState>(new Set()); + + const toggleSection = (idx: number) => { + const next = new Set(collapsedSections); + if (next.has(idx)) { + next.delete(idx); + } else { + next.add(idx); + } + setCollapsedSections(next); + }; + + if (sections.length === 0) { + return ( +
+
+
+
+ +

临床专业评估

+
+

基于 FINER 标准的研究选题系统评估

+
+
+ {data.report} +
+
+
+ ); + } + + return ( +
+ {/* 总览卡片 */} +
+
+
+ +

临床专业评估

+
+ {summary ? ( +
+ {summary} +
+ ) : ( +

基于 FINER 标准的研究选题系统评估

+ )} +
+
+ {sections.length} 个评估维度 +
+
+
+
+ + {/* 分项标题 */} +
+ +

分项评估

+ 共 {sections.length} 项 +
+ + {/* 各评估维度卡片 */} +
+ {sections.map((section, idx) => { + const style = getSectionIcon(section.title); + const isCollapsed = collapsedSections.has(idx); + + return ( +
+
toggleSection(idx)} + > +
+
+ +

{section.title}

+
+ {isCollapsed ? ( + + ) : ( + + )} +
+
+ + {!isCollapsed && section.content && ( +
+ {section.content} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/frontend-v2/src/modules/rvw/components/EditorialReport.tsx b/frontend-v2/src/modules/rvw/components/EditorialReport.tsx index e49ff493..e8b25605 100644 --- a/frontend-v2/src/modules/rvw/components/EditorialReport.tsx +++ b/frontend-v2/src/modules/rvw/components/EditorialReport.tsx @@ -9,7 +9,6 @@ interface EditorialReportProps { } export default function EditorialReport({ data }: EditorialReportProps) { - // 统计各状态数量 const stats = { pass: data.items.filter(item => item.status === 'pass').length, warning: data.items.filter(item => item.status === 'warning').length, @@ -46,63 +45,34 @@ export default function EditorialReport({ data }: EditorialReportProps) { } }; - const getScoreGrade = (score: number) => { - if (score >= 90) return { label: '优秀', color: 'text-green-600', bg: 'bg-green-500' }; - if (score >= 80) return { label: '良好', color: 'text-green-600', bg: 'bg-green-500' }; - if (score >= 70) return { label: '中等', color: 'text-amber-600', bg: 'bg-amber-500' }; - if (score >= 60) return { label: '及格', color: 'text-amber-600', bg: 'bg-amber-500' }; - return { label: '不及格', color: 'text-red-600', bg: 'bg-red-500' }; - }; - - const grade = getScoreGrade(data.overall_score); - return (
- {/* 评分总览卡片 */} + {/* 总览卡片 */}
-
- {/* 分数环 */} -
-
-
- {Number(data.overall_score).toFixed(1)} - -
-
- - {grade.label} - +
+ +

稿约规范性评估

+
+

{data.summary}

+ +
+
+ + {stats.pass} 项通过
- - {/* 评估摘要 */} -
-
- -

稿约规范性评估

+ {stats.warning > 0 && ( +
+ + {stats.warning} 项警告
-

{data.summary}

- - {/* 统计指标 */} -
-
- - {stats.pass} 项通过 -
- {stats.warning > 0 && ( -
- - {stats.warning} 项警告 -
- )} - {stats.fail > 0 && ( -
- - {stats.fail} 项不通过 -
- )} + )} + {stats.fail > 0 && ( +
+ + {stats.fail} 项不通过
-
+ )}
@@ -130,14 +100,9 @@ export default function EditorialReport({ data }: EditorialReportProps) { {getStatusIcon(item.status)}

{item.criterion}

-
- - {item.score}分 - - - {getStatusLabel(item.status)} - -
+ + {getStatusLabel(item.status)} +
diff --git a/frontend-v2/src/modules/rvw/components/ForensicsReport.tsx b/frontend-v2/src/modules/rvw/components/ForensicsReport.tsx index c3af88ec..7d8ceaa0 100644 --- a/frontend-v2/src/modules/rvw/components/ForensicsReport.tsx +++ b/frontend-v2/src/modules/rvw/components/ForensicsReport.tsx @@ -15,6 +15,8 @@ import { MousePointerClick } from 'lucide-react'; import type { ForensicsResult, ForensicsIssue, ForensicsTable } from '../types'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; interface ForensicsReportProps { data: ForensicsResult; @@ -65,7 +67,13 @@ const ISSUE_TYPE_LABELS: Record = { }; export default function ForensicsReport({ data }: ForensicsReportProps) { - const [expandedTables, setExpandedTables] = useState>(new Set()); + const hasLlm = !!(data?.llmReport || Object.keys(data?.llmTableReports || {}).length > 0); + const [expandedTables, setExpandedTables] = useState>(() => { + if (hasLlm) { + return new Set((data?.tables || []).map(t => t.id)); + } + return new Set(); + }); const [highlightedCell, setHighlightedCell] = useState(null); // 防御性检查:确保所有数组和对象存在 @@ -73,6 +81,7 @@ export default function ForensicsReport({ data }: ForensicsReportProps) { const issues = data?.issues || []; const methods = data?.methods || []; const summary = data?.summary || { totalTables: 0, totalIssues: 0, errorCount: 0, warningCount: 0 }; + const llmTableReports = data?.llmTableReports || {}; // 创建 tableId -> caption 映射,用于显示友好的表格名称 const tableIdToCaption: Record = {}; @@ -271,6 +280,7 @@ export default function ForensicsReport({ data }: ForensicsReportProps) { expanded={expandedTables.has(table.id)} onToggle={() => toggleTable(table.id)} highlightedCell={highlightedCell} + llmTableReport={llmTableReports[table.id]} /> ))}
@@ -296,9 +306,10 @@ interface TableCardProps { expanded: boolean; onToggle: () => void; highlightedCell: string | null; + llmTableReport?: string; } -function TableCard({ table, expanded, onToggle, highlightedCell }: TableCardProps) { +function TableCard({ table, expanded, onToggle, highlightedCell, llmTableReport }: TableCardProps) { // 防御性检查:确保 issues 数组存在 const issues = table.issues || []; const hasIssues = issues.length > 0; @@ -397,12 +408,27 @@ function TableCard({ table, expanded, onToggle, highlightedCell }: TableCardProp />
- {/* 表格问题 */} + {/* LLM 核查结果(该表格) */} + {llmTableReport && ( +
+
+

+ + AI 核查结果 +

+
+ {llmTableReport} +
+
+
+ )} + + {/* 规则验证详情 */} {issues.length > 0 && (

- 该表格发现的问题 + 规则验证详情

{issues.map((issue, idx) => (
diff --git a/frontend-v2/src/modules/rvw/components/MethodologyReport.tsx b/frontend-v2/src/modules/rvw/components/MethodologyReport.tsx index 12ac76a0..d2c5344f 100644 --- a/frontend-v2/src/modules/rvw/components/MethodologyReport.tsx +++ b/frontend-v2/src/modules/rvw/components/MethodologyReport.tsx @@ -9,7 +9,6 @@ interface MethodologyReportProps { } export default function MethodologyReport({ data }: MethodologyReportProps) { - // 统计问题数量 const totalIssues = data.parts.reduce((sum, part) => sum + part.issues.length, 0); const majorIssues = data.parts.reduce((sum, part) => sum + part.issues.filter(i => i.severity === 'major').length, 0); const minorIssues = totalIssues - majorIssues; @@ -20,81 +19,42 @@ export default function MethodologyReport({ data }: MethodologyReportProps) { : { icon: , label: '轻微', badge: 'bg-amber-100 text-amber-700 border-amber-200' }; }; - const getScoreGrade = (score: number) => { - if (score >= 90) return { label: '优秀', color: 'text-green-600', bg: 'bg-green-500' }; - if (score >= 80) return { label: '良好', color: 'text-green-600', bg: 'bg-green-500' }; - if (score >= 70) return { label: '中等', color: 'text-amber-600', bg: 'bg-amber-500' }; - if (score >= 60) return { label: '及格', color: 'text-amber-600', bg: 'bg-amber-500' }; - return { label: '不及格', color: 'text-red-600', bg: 'bg-red-500' }; - }; - - const getOverallStatus = () => { - if (data.overall_score >= 80) return { label: '通过', color: 'text-green-700', bg: 'bg-green-50', border: 'border-green-200' }; - if (data.overall_score >= 60) return { label: '存疑', color: 'text-amber-700', bg: 'bg-amber-50', border: 'border-amber-200' }; - return { label: '不通过', color: 'text-red-700', bg: 'bg-red-50', border: 'border-red-200' }; - }; - - const grade = getScoreGrade(data.overall_score); - const status = getOverallStatus(); - return (
- {/* 评分总览卡片 */} + {/* 总览卡片 */}
-
- {/* 分数环 */} -
-
-
- {Number(data.overall_score).toFixed(1)} - -
-
- - {grade.label} - +
+ +

方法学评估

+
+

{data.summary}

+ +
+
+ 共检测 {data.parts.length} 个方面
- - {/* 评估摘要 */} -
-
- -

方法学评估

- - {status.label} - + {totalIssues === 0 ? ( +
+ + 未发现问题
-

{data.summary}

- - {/* 统计指标 */} -
-
- 共检测 {data.parts.length} 个方面 -
- {totalIssues === 0 ? ( -
- - 未发现问题 + ) : ( + <> + {majorIssues > 0 && ( +
+ + {majorIssues} 个严重问题
- ) : ( - <> - {majorIssues > 0 && ( -
- - {majorIssues} 个严重问题 -
- )} - {minorIssues > 0 && ( -
- - {minorIssues} 个轻微问题 -
- )} - )} -
-
+ {minorIssues > 0 && ( +
+ + {minorIssues} 个轻微问题 +
+ )} + + )}
@@ -121,24 +81,16 @@ export default function MethodologyReport({ data }: MethodologyReportProps) { )}

{part.part}

-
- = 80 ? 'bg-green-100 text-green-700' : - part.score >= 60 ? 'bg-amber-100 text-amber-700' : 'bg-red-100 text-red-700' - }`}> - {part.score}分 + {part.issues.length === 0 ? ( + + + 无问题 - {part.issues.length === 0 ? ( - - - 无问题 - - ) : ( - - {part.issues.length} 个问题 - - )} -
+ ) : ( + + {part.issues.length} 个问题 + + )}
diff --git a/frontend-v2/src/modules/rvw/components/TaskDetail.tsx b/frontend-v2/src/modules/rvw/components/TaskDetail.tsx index 0254be08..5c4a5b69 100644 --- a/frontend-v2/src/modules/rvw/components/TaskDetail.tsx +++ b/frontend-v2/src/modules/rvw/components/TaskDetail.tsx @@ -3,17 +3,18 @@ * 支持显示审稿进度和结果 */ import { useState, useEffect } from 'react'; -import { ArrowLeft, FileCheck, Clock, AlertCircle, CheckCircle, Loader2, FileText, Bot, Info } from 'lucide-react'; +import { ArrowLeft, FileCheck, Clock, AlertCircle, CheckCircle, Loader2, FileText, Bot, Info, AlertTriangle } from 'lucide-react'; import { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType, Table, TableRow, TableCell, WidthType } from 'docx'; import { saveAs } from 'file-saver'; import type { ReviewTask, ReviewReport, TaskStatus } from '../types'; import EditorialReport from './EditorialReport'; import MethodologyReport from './MethodologyReport'; import ForensicsReport from './ForensicsReport'; +import ClinicalReport from './ClinicalReport'; import * as api from '../api'; import { message } from 'antd'; -type TabType = 'editorial' | 'methodology' | 'forensics'; +type TabType = 'editorial' | 'methodology' | 'forensics' | 'clinical'; interface TaskDetailProps { task: ReviewTask; @@ -29,6 +30,7 @@ const STATUS_INFO: Record { if (selectedAgents.includes('methodology')) { steps.push({ key: 'methodology', label: '方法学评估' }); } + if (selectedAgents.includes('clinical')) { + steps.push({ key: 'clinical', label: '临床评估' }); + } return steps; }; @@ -59,7 +64,8 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet void jobId; const isProcessing = ['extracting', 'reviewing', 'reviewing_editorial', 'reviewing_methodology'].includes(task.status); - const isCompleted = task.status === 'completed'; + const isCompleted = task.status === 'completed' || task.status === 'partial_completed'; + const isPartial = task.status === 'partial_completed'; const isFailed = task.status === 'failed'; // 轮询任务状态 @@ -71,8 +77,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet const updated = await api.getTask(task.id); setTask(updated); - // 如果完成了,加载报告 - if (updated.status === 'completed') { + if (updated.status === 'completed' || updated.status === 'partial_completed') { const reportData = await api.getTaskReport(task.id); setReport(reportData); } @@ -108,13 +113,14 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet // 报告加载后自动设置正确的 Tab useEffect(() => { if (report) { - // 优先显示有数据的 Tab if (report.editorialReview) { setActiveTab('editorial'); } else if (report.methodologyReview) { setActiveTab('methodology'); } else if (report.forensicsResult) { setActiveTab('forensics'); + } else if (report.clinicalReview) { + setActiveTab('clinical'); } } }, [report]); @@ -146,7 +152,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet if (stepKey === 'methodology' && hasMethodology) return 'active'; return 'pending'; } - if (task.status === 'completed') return 'completed'; + if (task.status === 'completed' || task.status === 'partial_completed') return 'completed'; if (task.status === 'failed') { if (['upload', 'extract'].includes(stepKey)) return 'completed'; return 'pending'; @@ -197,11 +203,11 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet new TableRow({ children: [ new TableCell({ - children: [new Paragraph({ children: [new TextRun({ text: '综合评分', bold: true })] })], + children: [new Paragraph({ children: [new TextRun({ text: '审查维度', bold: true })] })], width: { size: 2000, type: WidthType.DXA }, }), new TableCell({ - children: [new Paragraph(`${report.overallScore != null ? Number(report.overallScore).toFixed(1) : '-'} 分`)], + children: [new Paragraph(report.selectedAgents?.map(a => a === 'editorial' ? '稿约规范性' : a === 'methodology' ? '方法学' : a === 'clinical' ? '临床评估' : a).join('、') || '-')], width: { size: 7000, type: WidthType.DXA }, }), ], @@ -244,7 +250,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet if (report.editorialReview) { children.push( new Paragraph({ - text: `一、稿约规范性评估(${report.editorialReview.overall_score}分)`, + text: '一、稿约规范性评估', heading: HeadingLevel.HEADING_1, spacing: { before: 400, after: 200 }, }) @@ -262,7 +268,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet children.push( new Paragraph({ - text: `${i + 1}. ${item.criterion}(${item.score}分)- ${statusText}`, + text: `${i + 1}. ${item.criterion} - ${statusText}`, heading: HeadingLevel.HEADING_2, spacing: { before: 200, after: 100 }, }) @@ -307,7 +313,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet if (report.methodologyReview) { children.push( new Paragraph({ - text: `二、方法学评估(${report.methodologyReview.overall_score}分)`, + text: '二、方法学评估', heading: HeadingLevel.HEADING_1, spacing: { before: 400, after: 200 }, }) @@ -321,13 +327,13 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet ); report.methodologyReview.parts.forEach((part) => { - children.push( - new Paragraph({ - text: `${part.part}(${part.score}分)`, - heading: HeadingLevel.HEADING_2, - spacing: { before: 200, after: 100 }, - }) - ); + children.push( + new Paragraph({ + text: part.part, + heading: HeadingLevel.HEADING_2, + spacing: { before: 200, after: 100 }, + }) + ); if (part.issues.length === 0) { children.push( @@ -369,6 +375,41 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet }); } + // 临床专业评估 + if (report.clinicalReview) { + const sectionNum = report.methodologyReview ? '三' : '二'; + children.push( + new Paragraph({ + text: `${sectionNum}、临床专业评估`, + heading: HeadingLevel.HEADING_1, + spacing: { before: 400, after: 200 }, + }) + ); + + const clinicalLines = report.clinicalReview.report.split('\n'); + for (const line of clinicalLines) { + if (!line.trim()) continue; + if (line.startsWith('# ')) { + children.push(new Paragraph({ + text: line.replace(/^#+\s*/, ''), + heading: HeadingLevel.HEADING_2, + spacing: { before: 200, after: 100 }, + })); + } else if (line.startsWith('## ') || line.startsWith('### ')) { + children.push(new Paragraph({ + text: line.replace(/^#+\s*/, ''), + heading: HeadingLevel.HEADING_2, + spacing: { before: 150, after: 80 }, + })); + } else { + children.push(new Paragraph({ + text: line, + spacing: { after: 60 }, + })); + } + } + } + // 页脚 children.push( new Paragraph({ @@ -525,19 +566,50 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
)} + {/* 部分完成警告 */} + {isPartial && task.errorDetails && ( +
+
+ +
+

部分模块未能完成

+

+ {task.errorDetails.successCount} 个模块成功,{task.errorDetails.errorCount + task.errorDetails.timeoutCount} 个模块失败。已展示成功模块的结果。 +

+
+ {task.errorDetails.failedSkills.map((skill, i) => ( +
+ + {skill.status === 'timeout' ? '超时' : '失败'} + + {skill.skillName} + — {skill.error} +
+ ))} +
+
+
+
+ )} + {/* 完成状态 - 显示报告 */} {isCompleted && report && ( <> - {/* 分数卡片 */} -
+ {/* 审查完成信息 */} +
-
-

综合评分

-

- 审查用时 {report.durationSeconds ? formatTime(report.durationSeconds) : '-'} -

+
+ +
+

审查完成

+

+ 审查用时 {report.durationSeconds ? formatTime(report.durationSeconds) : '-'} + {report.completedAt && ` · ${new Date(report.completedAt).toLocaleString('zh-CN')}`} +

+
-
{report.overallScore != null ? Number(report.overallScore).toFixed(1) : '-'}
@@ -552,7 +624,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet : 'font-medium text-slate-500 hover:text-slate-700' }`} > - 稿约规范性 ({report.editorialReview.overall_score}分) + 稿约规范性 )} {report.methodologyReview && ( @@ -564,7 +636,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet : 'font-medium text-slate-500 hover:text-slate-700' }`} > - 方法学评估 ({report.methodologyReview.overall_score}分) + 方法学评估 )} {report.forensicsResult && ( @@ -579,6 +651,18 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet 数据验证 ({report.forensicsResult.summary.totalIssues || 0}个问题) )} + {report.clinicalReview && ( + + )}
{/* 非 docx 文件无数据验证提示 */} @@ -611,6 +695,9 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet {activeTab === 'forensics' && report.forensicsResult && ( )} + {activeTab === 'clinical' && report.clinicalReview && ( + + )} )}
diff --git a/frontend-v2/src/modules/rvw/components/TaskTable.tsx b/frontend-v2/src/modules/rvw/components/TaskTable.tsx index d19c5f99..7a031664 100644 --- a/frontend-v2/src/modules/rvw/components/TaskTable.tsx +++ b/frontend-v2/src/modules/rvw/components/TaskTable.tsx @@ -111,8 +111,8 @@ export default function TaskTable({ ); } - // 已完成:[查看报告] [重新审稿] [删除] - if (task.status === 'completed') { + // 已完成 / 部分完成:[查看报告] [重新审稿] [删除] + if (task.status === 'completed' || task.status === 'partial_completed') { return (