feat(rvw,asl): RVW V3.0 smart review + ASL deep research history + stability
RVW module (V3.0 Smart Review Enhancement): - Add LLM data validation via PromptService (RVW_DATA_VALIDATION) - Add ClinicalAssessmentSkill with FINER-based evaluation (RVW_CLINICAL) - Remove all numeric scores from UI (editorial, methodology, overall) - Implement partial_completed status with Promise.allSettled - Add error_details JSON field to ReviewTask for granular failure info - Fix overallStatus logic: warning status now counts as success - Restructure ForensicsReport: per-table LLM results, remove top-level block - Refactor ClinicalReport: structured collapsible sections - Increase all skill timeouts to 300s for long manuscripts (20+ pages) - Increase DataForensics LLM timeout to 180s, pg-boss to 15min - Executor default fallback timeout 30s -> 60s ASL module: - Add deep research history with sidebar accordion UI - Implement waterfall flow for historical task display - Upgrade Unifuncs DeepSearch API from S2 to S3 with fallback - Add ASL_SR module seed for admin configurability - Fix new search button inconsistency Docs: - Update RVW module status to V3.0 - Update deployment changelist - Add 0305 deployment summary DB Migration: - Add error_details JSONB column to rvw_schema.review_tasks Tested: All 4 review modules verified, partial completion working Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
-- AlterTable: ReviewTask 新增 error_details 字段
|
||||
-- 用于存储结构化的错误详情(各 Skill 成功/失败状态),支持 partial_completed 场景
|
||||
ALTER TABLE "rvw_schema"."review_tasks" ADD COLUMN "error_details" JSONB;
|
||||
@@ -285,6 +285,7 @@ model ReviewTask {
|
||||
methodologyStatus String? @map("methodology_status")
|
||||
picoExtract Json? @map("pico_extract")
|
||||
contextData Json? @map("context_data") /// Skills V2.0 执行上下文数据
|
||||
errorDetails Json? @map("error_details") /// 结构化错误详情(记录各 Skill 成功/失败状态)
|
||||
isArchived Boolean @default(false) @map("is_archived")
|
||||
archivedAt DateTime? @map("archived_at")
|
||||
modelUsed String? @map("model_used")
|
||||
|
||||
@@ -34,7 +34,7 @@ function extractVariables(content: string): string[] {
|
||||
return Array.from(variables);
|
||||
}
|
||||
|
||||
// RVW Prompt 配置(只有 2 个)
|
||||
// RVW Prompt 配置
|
||||
// 注意:topic_evaluation_* 是"选题评估"功能,不属于 RVW 审稿模块
|
||||
const rvwPrompts = [
|
||||
{
|
||||
@@ -53,6 +53,55 @@ const rvwPrompts = [
|
||||
file: 'review_methodology_system.txt',
|
||||
modelConfig: { model: 'deepseek-v3', temperature: 0.3 },
|
||||
},
|
||||
{
|
||||
code: 'RVW_DATA_VALIDATION',
|
||||
name: '数据验证 LLM 核查',
|
||||
module: 'RVW',
|
||||
description: '使用大语言模型对医学科研稿件中的表格进行核查,包括百分比计算、统计检验方法、统计分析结果准确性等。',
|
||||
inlineContent: `你正在处理的是医学科研稿件,请对附件中的表格进行核查,包括百分比计算是准确,统计检验方法使用是否正确,统计分析检验结果是否准确,卡方检验中如果适用fisher精确检验的条件,不给卡方值不是问题,请忽略。最终形成一个核查报告,重点列出核查出的问题。
|
||||
|
||||
请按表格逐个输出核查结果,使用以下格式:
|
||||
## 表N: <表格标题>
|
||||
<该表格的核查结论和发现的问题>`,
|
||||
modelConfig: { model: 'deepseek-v3', temperature: 0.3 },
|
||||
},
|
||||
{
|
||||
code: 'RVW_CLINICAL',
|
||||
name: '临床专业评估',
|
||||
module: 'RVW',
|
||||
description: '基于 FINER 标准(可行性、创新性、伦理性、相关性)对研究选题进行系统评估,涵盖创新性、临床价值、科学性、可行性四个维度。',
|
||||
inlineContent: `你作为临床研究设计智能顾问(CRD-IA),将依据 FINER 标准(可行性 Feasibility、创新性 Interesting、伦理性 Ethical、相关性 Relevant)对研究选题进行系统评估并用中文回答。
|
||||
第一步:研究问题的明确性评估
|
||||
1. 判断研究问题是否清晰
|
||||
研究问题是否包含完整的 PICO 要素(Population/Intervention/Comparator/Outcome)。
|
||||
若 PICO 不完整,提示研究者补充必要信息。
|
||||
2. 研究问题的完善与优化
|
||||
研究者已有明确的临床问题:通过对话识别其陈述中的关键信息,优化 PICO 框架。
|
||||
研究者尚未形成清晰的研究问题:询问其关注的疾病领域,并协助提出可供研究的具体问题。
|
||||
判断是否需进一步咨询专家:若研究问题仍不够明确,建议研究者寻求该领域专家的意见。
|
||||
第二步:研究问题的要素完整性验证
|
||||
CRD-IA 将按以下维度评估研究问题的完整性,确保其符合 FINER 标准,并依据 循证医学原则 和 ICH-GCP 规范 进行多维度价值评估。评估逻辑包括 假设解构 → 知识验证 → 缺陷识别 → 优化建议,所有结论需明确 证据等级(A/B/C类)。
|
||||
1. 创新性评估
|
||||
检索国际指南、PubMed 已发表论文,以及 ICTRP、ClinicalTrials.gov 近三年注册研究,分析研究选题的 相似度(相似度<30%为高创新)。
|
||||
识别研究假设中的 知识突破点,判断是否填补现有研究空白。
|
||||
2. 临床价值评估
|
||||
通过 PubMed 检索该疾病的 疾病负担指数(参考最新 GBD 数据),判断该研究的 临床紧迫性。
|
||||
检索该疾病相关的 国际指南,明确指南是否指出该问题 需要进一步证据。
|
||||
评估研究者定义的 结局指标 是否与临床关注的核心获益一致;如偏离临床重点,应予以提示。
|
||||
3. 科学性评估
|
||||
研究假设是否 符合基本科学原理,若存在与已知科学常识矛盾的部分,应提示研究者重新审视理论基础。
|
||||
该研究问题能否通过 合理的研究设计 进行科学验证。
|
||||
4. 可行性评估
|
||||
进行 风险-受益比分析(基于 DECISION 模型),评估该研究是否存在 重大伦理风险,影响可行性。
|
||||
估算目标患者群体的 潜在样本量,若可能难以收集足够样本,应明确指出并建议调整研究方案。
|
||||
第三步:最终评估结论与优化建议
|
||||
在综合分析 创新性、临床价值、科学性、可行性 之后,CRD-IA 将:
|
||||
总结研究选题的整体评估结果,标明各项评估的 证据等级(A/B/C类)。
|
||||
提出优化建议,帮助研究者改进研究设计,使其更具科学价值、临床意义和可操作性。
|
||||
回答需要考虑聊天历史。
|
||||
如果过程中有不明确的问题,通过聊天让用户补充相关信息。除特殊要求外,用中文回复。`,
|
||||
modelConfig: { model: 'deepseek-v3', temperature: 0.3 },
|
||||
},
|
||||
];
|
||||
|
||||
async function main() {
|
||||
@@ -63,14 +112,20 @@ async function main() {
|
||||
for (const prompt of rvwPrompts) {
|
||||
console.log(`📄 处理: ${prompt.code} (${prompt.name})`);
|
||||
|
||||
// 读取文件内容
|
||||
const filePath = path.join(promptsDir, prompt.file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.log(` ⚠️ 文件不存在: ${filePath}`);
|
||||
continue;
|
||||
// 读取内容:优先使用 inlineContent,否则从文件读取
|
||||
let content: string;
|
||||
if ((prompt as any).inlineContent) {
|
||||
content = (prompt as any).inlineContent;
|
||||
console.log(` 📝 使用内联内容,长度: ${content.length} 字符`);
|
||||
} else {
|
||||
const filePath = path.join(promptsDir, (prompt as any).file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.log(` ⚠️ 文件不存在: ${filePath}`);
|
||||
continue;
|
||||
}
|
||||
content = fs.readFileSync(filePath, 'utf-8').trim();
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8').trim();
|
||||
const variables = extractVariables(content);
|
||||
|
||||
console.log(` 📝 内容长度: ${content.length} 字符`);
|
||||
|
||||
@@ -89,6 +89,14 @@ const MODULES = [
|
||||
is_active: true,
|
||||
sort_order: 100,
|
||||
},
|
||||
{
|
||||
code: 'ASL_SR',
|
||||
name: '系统综述项目',
|
||||
description: 'AI智能文献模块内的系统综述全流程功能(初筛/复筛/提取/图表/Meta),可按用户/租户独立配置开关',
|
||||
icon: 'FolderOutlined',
|
||||
is_active: true,
|
||||
sort_order: 101,
|
||||
},
|
||||
];
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -52,6 +52,49 @@ const RVW_FALLBACKS: Record<string, FallbackPrompt> = {
|
||||
请输出JSON格式的评估结果,包含overall_score和parts数组。`,
|
||||
modelConfig: { model: 'deepseek-v3', temperature: 0.3 },
|
||||
},
|
||||
|
||||
RVW_DATA_VALIDATION: {
|
||||
content: `你正在处理的是医学科研稿件,请对附件中的表格进行核查,包括百分比计算是准确,统计检验方法使用是否正确,统计分析检验结果是否准确,卡方检验中如果适用fisher精确检验的条件,不给卡方值不是问题,请忽略。最终形成一个核查报告,重点列出核查出的问题。
|
||||
|
||||
请按表格逐个输出核查结果,使用以下格式:
|
||||
## 表N: <表格标题>
|
||||
<该表格的核查结论和发现的问题>`,
|
||||
modelConfig: { model: 'deepseek-v3', temperature: 0.3 },
|
||||
},
|
||||
|
||||
RVW_CLINICAL: {
|
||||
content: `你作为临床研究设计智能顾问(CRD-IA),将依据 FINER 标准(可行性 Feasibility、创新性 Interesting、伦理性 Ethical、相关性 Relevant)对研究选题进行系统评估并用中文回答。
|
||||
第一步:研究问题的明确性评估
|
||||
1. 判断研究问题是否清晰
|
||||
研究问题是否包含完整的 PICO 要素(Population/Intervention/Comparator/Outcome)。
|
||||
若 PICO 不完整,提示研究者补充必要信息。
|
||||
2. 研究问题的完善与优化
|
||||
研究者已有明确的临床问题:通过对话识别其陈述中的关键信息,优化 PICO 框架。
|
||||
研究者尚未形成清晰的研究问题:询问其关注的疾病领域,并协助提出可供研究的具体问题。
|
||||
判断是否需进一步咨询专家:若研究问题仍不够明确,建议研究者寻求该领域专家的意见。
|
||||
第二步:研究问题的要素完整性验证
|
||||
CRD-IA 将按以下维度评估研究问题的完整性,确保其符合 FINER 标准,并依据 循证医学原则 和 ICH-GCP 规范 进行多维度价值评估。评估逻辑包括 假设解构 → 知识验证 → 缺陷识别 → 优化建议,所有结论需明确 证据等级(A/B/C类)。
|
||||
1. 创新性评估
|
||||
检索国际指南、PubMed 已发表论文,以及 ICTRP、ClinicalTrials.gov 近三年注册研究,分析研究选题的 相似度(相似度<30%为高创新)。
|
||||
识别研究假设中的 知识突破点,判断是否填补现有研究空白。
|
||||
2. 临床价值评估
|
||||
通过 PubMed 检索该疾病的 疾病负担指数(参考最新 GBD 数据),判断该研究的 临床紧迫性。
|
||||
检索该疾病相关的 国际指南,明确指南是否指出该问题 需要进一步证据。
|
||||
评估研究者定义的 结局指标 是否与临床关注的核心获益一致;如偏离临床重点,应予以提示。
|
||||
3. 科学性评估
|
||||
研究假设是否 符合基本科学原理,若存在与已知科学常识矛盾的部分,应提示研究者重新审视理论基础。
|
||||
该研究问题能否通过 合理的研究设计 进行科学验证。
|
||||
4. 可行性评估
|
||||
进行 风险-受益比分析(基于 DECISION 模型),评估该研究是否存在 重大伦理风险,影响可行性。
|
||||
估算目标患者群体的 潜在样本量,若可能难以收集足够样本,应明确指出并建议调整研究方案。
|
||||
第三步:最终评估结论与优化建议
|
||||
在综合分析 创新性、临床价值、科学性、可行性 之后,CRD-IA 将:
|
||||
总结研究选题的整体评估结果,标明各项评估的 证据等级(A/B/C类)。
|
||||
提出优化建议,帮助研究者改进研究设计,使其更具科学价值、临床意义和可操作性。
|
||||
回答需要考虑聊天历史。
|
||||
如果过程中有不明确的问题,通过聊天让用户补充相关信息。除特殊要求外,用中文回复。`,
|
||||
modelConfig: { model: 'deepseek-v3', temperature: 0.3 },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ import { authenticate, requireModule } from '../../../../common/auth/auth.middle
|
||||
export async function chartingRoutes(fastify: FastifyInstance) {
|
||||
fastify.addHook('onRequest', authenticate);
|
||||
fastify.addHook('onRequest', requireModule('ASL'));
|
||||
fastify.addHook('onRequest', requireModule('ASL_SR'));
|
||||
|
||||
fastify.get('/prisma-data/:projectId', chartingController.getPrismaData);
|
||||
fastify.get('/baseline-data/:projectId', chartingController.getBaselineData);
|
||||
|
||||
@@ -131,6 +131,82 @@ export async function executeTask(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── GET /research/v2/tasks ─────────────────────
|
||||
|
||||
export async function listTasks(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const userId = request.user?.userId;
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ success: false, error: '用户未认证' });
|
||||
}
|
||||
|
||||
const tasks = await prisma.aslResearchTask.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: { not: 'draft' },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
select: {
|
||||
id: true,
|
||||
query: true,
|
||||
status: true,
|
||||
resultCount: true,
|
||||
createdAt: true,
|
||||
completedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return reply.send({ success: true, data: tasks });
|
||||
} catch (error: any) {
|
||||
logger.error('[DeepResearchController] listTasks failed', {
|
||||
error: error.message,
|
||||
});
|
||||
return reply.code(500).send({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DELETE /research/tasks/:taskId ─────────────
|
||||
|
||||
export async function deleteTask(
|
||||
request: FastifyRequest<{ Params: TaskParams }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const userId = request.user?.userId;
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ success: false, error: '用户未认证' });
|
||||
}
|
||||
|
||||
const { taskId } = request.params;
|
||||
|
||||
const task = await prisma.aslResearchTask.findUnique({
|
||||
where: { id: taskId },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
return reply.code(404).send({ success: false, error: '任务不存在' });
|
||||
}
|
||||
|
||||
if (task.userId !== userId) {
|
||||
return reply.code(403).send({ success: false, error: '无权删除此任务' });
|
||||
}
|
||||
|
||||
await prisma.aslResearchTask.delete({ where: { id: taskId } });
|
||||
|
||||
return reply.send({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error('[DeepResearchController] deleteTask failed', {
|
||||
error: error.message,
|
||||
});
|
||||
return reply.code(500).send({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── GET /research/tasks/:taskId ────────────────
|
||||
|
||||
export async function getTask(
|
||||
@@ -138,6 +214,11 @@ export async function getTask(
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const userId = request.user?.userId;
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ success: false, error: '用户未认证' });
|
||||
}
|
||||
|
||||
const { taskId } = request.params;
|
||||
|
||||
const task = await prisma.aslResearchTask.findUnique({
|
||||
@@ -148,6 +229,10 @@ export async function getTask(
|
||||
return reply.code(404).send({ success: false, error: '任务不存在' });
|
||||
}
|
||||
|
||||
if (task.userId !== userId) {
|
||||
return reply.code(403).send({ success: false, error: '无权查看此任务' });
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
|
||||
@@ -17,6 +17,7 @@ export async function extractionRoutes(fastify: FastifyInstance) {
|
||||
fastify.register(async function authedRoutes(sub) {
|
||||
sub.addHook('onRequest', authenticate);
|
||||
sub.addHook('onRequest', requireModule('ASL'));
|
||||
sub.addHook('onRequest', requireModule('ASL_SR'));
|
||||
|
||||
// ── 模板 API ──────────────────────────────
|
||||
sub.get('/templates', ctrl.listTemplates);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { authenticate, requireModule } from '../../../../common/auth/auth.middle
|
||||
export async function metaAnalysisRoutes(fastify: FastifyInstance) {
|
||||
fastify.addHook('preHandler', authenticate);
|
||||
fastify.addHook('preHandler', requireModule('ASL'));
|
||||
fastify.addHook('preHandler', requireModule('ASL_SR'));
|
||||
|
||||
fastify.post('/run', ctrl.runAnalysis);
|
||||
fastify.get('/project-data/:projectId', ctrl.getProjectData);
|
||||
|
||||
@@ -15,73 +15,39 @@ import { metaAnalysisRoutes } from '../meta-analysis/routes/index.js';
|
||||
import { authenticate, requireModule } from '../../../common/auth/auth.middleware.js';
|
||||
|
||||
export async function aslRoutes(fastify: FastifyInstance) {
|
||||
// ==================== 筛选项目路由 ====================
|
||||
|
||||
// 创建筛选项目
|
||||
fastify.post('/projects', { preHandler: [authenticate, requireModule('ASL')] }, projectController.createProject);
|
||||
|
||||
// 获取用户的所有项目
|
||||
fastify.get('/projects', { preHandler: [authenticate, requireModule('ASL')] }, projectController.getProjects);
|
||||
|
||||
// 获取单个项目详情
|
||||
fastify.get('/projects/:projectId', { preHandler: [authenticate, requireModule('ASL')] }, projectController.getProjectById);
|
||||
|
||||
// 更新项目
|
||||
fastify.put('/projects/:projectId', { preHandler: [authenticate, requireModule('ASL')] }, projectController.updateProject);
|
||||
|
||||
// 删除项目
|
||||
fastify.delete('/projects/:projectId', { preHandler: [authenticate, requireModule('ASL')] }, projectController.deleteProject);
|
||||
// SR 子模块公共 preHandler
|
||||
const srAuth = [authenticate, requireModule('ASL'), requireModule('ASL_SR')];
|
||||
|
||||
// ==================== 文献管理路由 ====================
|
||||
// ==================== 筛选项目路由 (需要 ASL_SR) ====================
|
||||
|
||||
// 导入文献(JSON)
|
||||
fastify.post('/literatures/import', { preHandler: [authenticate, requireModule('ASL')] }, literatureController.importLiteratures);
|
||||
|
||||
// 导入文献(Excel上传)
|
||||
fastify.post('/literatures/import-excel', { preHandler: [authenticate, requireModule('ASL')] }, literatureController.importLiteraturesFromExcel);
|
||||
|
||||
// 获取项目的文献列表
|
||||
fastify.get('/projects/:projectId/literatures', { preHandler: [authenticate, requireModule('ASL')] }, literatureController.getLiteratures);
|
||||
|
||||
// 删除文献
|
||||
fastify.delete('/literatures/:literatureId', { preHandler: [authenticate, requireModule('ASL')] }, literatureController.deleteLiterature);
|
||||
fastify.post('/projects', { preHandler: srAuth }, projectController.createProject);
|
||||
fastify.get('/projects', { preHandler: srAuth }, projectController.getProjects);
|
||||
fastify.get('/projects/:projectId', { preHandler: srAuth }, projectController.getProjectById);
|
||||
fastify.put('/projects/:projectId', { preHandler: srAuth }, projectController.updateProject);
|
||||
fastify.delete('/projects/:projectId', { preHandler: srAuth }, projectController.deleteProject);
|
||||
|
||||
// ==================== 筛选任务路由 ====================
|
||||
// ==================== 文献管理路由 (需要 ASL_SR) ====================
|
||||
|
||||
// 获取筛选任务进度
|
||||
fastify.get('/projects/:projectId/screening-task', { preHandler: [authenticate, requireModule('ASL')] }, screeningController.getScreeningTask);
|
||||
|
||||
// 获取筛选结果列表(分页)
|
||||
fastify.get('/projects/:projectId/screening-results', { preHandler: [authenticate, requireModule('ASL')] }, screeningController.getScreeningResults);
|
||||
|
||||
// 获取单个筛选结果详情
|
||||
fastify.get('/screening-results/:resultId', { preHandler: [authenticate, requireModule('ASL')] }, screeningController.getScreeningResultDetail);
|
||||
|
||||
// 提交人工复核
|
||||
fastify.post('/screening-results/:resultId/review', { preHandler: [authenticate, requireModule('ASL')] }, screeningController.reviewScreeningResult);
|
||||
|
||||
// ⭐ 获取项目统计数据(Week 4 新增)
|
||||
fastify.get('/projects/:projectId/statistics', { preHandler: [authenticate, requireModule('ASL')] }, screeningController.getProjectStatistics);
|
||||
|
||||
// TODO: 启动筛选任务(Week 2 Day 2 已实现为同步流程,异步版本待实现)
|
||||
// fastify.post('/projects/:projectId/screening/start', screeningController.startScreening);
|
||||
fastify.post('/literatures/import', { preHandler: srAuth }, literatureController.importLiteratures);
|
||||
fastify.post('/literatures/import-excel', { preHandler: srAuth }, literatureController.importLiteraturesFromExcel);
|
||||
fastify.get('/projects/:projectId/literatures', { preHandler: srAuth }, literatureController.getLiteratures);
|
||||
fastify.delete('/literatures/:literatureId', { preHandler: srAuth }, literatureController.deleteLiterature);
|
||||
|
||||
// ==================== 全文复筛路由 (Day 5 新增) ====================
|
||||
// ==================== 筛选任务路由 (需要 ASL_SR) ====================
|
||||
|
||||
// 创建全文复筛任务
|
||||
fastify.post('/fulltext-screening/tasks', { preHandler: [authenticate, requireModule('ASL')] }, fulltextScreeningController.createTask);
|
||||
fastify.get('/projects/:projectId/screening-task', { preHandler: srAuth }, screeningController.getScreeningTask);
|
||||
fastify.get('/projects/:projectId/screening-results', { preHandler: srAuth }, screeningController.getScreeningResults);
|
||||
fastify.get('/screening-results/:resultId', { preHandler: srAuth }, screeningController.getScreeningResultDetail);
|
||||
fastify.post('/screening-results/:resultId/review', { preHandler: srAuth }, screeningController.reviewScreeningResult);
|
||||
fastify.get('/projects/:projectId/statistics', { preHandler: srAuth }, screeningController.getProjectStatistics);
|
||||
|
||||
// ==================== 全文复筛路由 (需要 ASL_SR) ====================
|
||||
|
||||
// 获取任务进度
|
||||
fastify.get('/fulltext-screening/tasks/:taskId', { preHandler: [authenticate, requireModule('ASL')] }, fulltextScreeningController.getTaskProgress);
|
||||
|
||||
// 获取任务结果(支持筛选和分页)
|
||||
fastify.get('/fulltext-screening/tasks/:taskId/results', { preHandler: [authenticate, requireModule('ASL')] }, fulltextScreeningController.getTaskResults);
|
||||
|
||||
// 人工审核决策
|
||||
fastify.put('/fulltext-screening/results/:resultId/decision', { preHandler: [authenticate, requireModule('ASL')] }, fulltextScreeningController.updateDecision);
|
||||
|
||||
// 导出Excel
|
||||
fastify.get('/fulltext-screening/tasks/:taskId/export', { preHandler: [authenticate, requireModule('ASL')] }, fulltextScreeningController.exportExcel);
|
||||
fastify.post('/fulltext-screening/tasks', { preHandler: srAuth }, fulltextScreeningController.createTask);
|
||||
fastify.get('/fulltext-screening/tasks/:taskId', { preHandler: srAuth }, fulltextScreeningController.getTaskProgress);
|
||||
fastify.get('/fulltext-screening/tasks/:taskId/results', { preHandler: srAuth }, fulltextScreeningController.getTaskResults);
|
||||
fastify.put('/fulltext-screening/results/:resultId/decision', { preHandler: srAuth }, fulltextScreeningController.updateDecision);
|
||||
fastify.get('/fulltext-screening/tasks/:taskId/export', { preHandler: srAuth }, fulltextScreeningController.exportExcel);
|
||||
|
||||
// ==================== 智能文献检索路由 (DeepSearch V1.x — 保留兼容) ====================
|
||||
|
||||
@@ -105,6 +71,12 @@ export async function aslRoutes(fastify: FastifyInstance) {
|
||||
// 启动异步执行
|
||||
fastify.put('/research/tasks/:taskId/execute', { preHandler: [authenticate, requireModule('ASL')] }, deepResearchController.executeTask);
|
||||
|
||||
// V2.0 任务历史列表(最近 100 条)
|
||||
fastify.get('/research/v2/tasks', { preHandler: [authenticate, requireModule('ASL')] }, deepResearchController.listTasks);
|
||||
|
||||
// V2.0 删除任务
|
||||
fastify.delete('/research/tasks/:taskId', { preHandler: [authenticate, requireModule('ASL')] }, deepResearchController.deleteTask);
|
||||
|
||||
// V2.0 任务详情(状态 + 日志 + 结果)
|
||||
fastify.get('/research/tasks/:taskId', { preHandler: [authenticate, requireModule('ASL')] }, deepResearchController.getTask);
|
||||
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
*
|
||||
* 封装 create_task / query_task 两个 API。
|
||||
* Worker 通过此客户端与 Unifuncs 服务交互。
|
||||
*
|
||||
* 模型版本通过环境变量 UNIFUNCS_MODEL 控制,默认 s3。
|
||||
* 若 S3 出现问题,设置 UNIFUNCS_MODEL=s2 即可降级,无需改代码重部署。
|
||||
*/
|
||||
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
|
||||
const UNIFUNCS_BASE_URL = 'https://api.unifuncs.com/deepsearch/v1';
|
||||
const UNIFUNCS_API_KEY = process.env.UNIFUNCS_API_KEY;
|
||||
const UNIFUNCS_MODEL = process.env.UNIFUNCS_MODEL || 's3';
|
||||
|
||||
export interface CreateTaskParams {
|
||||
query: string;
|
||||
@@ -58,6 +62,14 @@ export interface QueryTaskResponse {
|
||||
total_tokens: number;
|
||||
};
|
||||
};
|
||||
session?: {
|
||||
session_id: string;
|
||||
status: string;
|
||||
model: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
question: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,8 +87,10 @@ class UnifuncsAsyncClient {
|
||||
throw new Error('UNIFUNCS_API_KEY not configured');
|
||||
}
|
||||
|
||||
const payload = {
|
||||
model: 's2',
|
||||
const model = UNIFUNCS_MODEL;
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
model,
|
||||
messages: [{ role: 'user', content: params.query }],
|
||||
introduction: params.introduction || this.defaultIntroduction(),
|
||||
max_depth: params.maxDepth ?? 25,
|
||||
@@ -87,7 +101,12 @@ class UnifuncsAsyncClient {
|
||||
...(params.outputPrompt ? { output_prompt: params.outputPrompt } : {}),
|
||||
};
|
||||
|
||||
if (model === 's3') {
|
||||
payload.language = 'zh';
|
||||
}
|
||||
|
||||
logger.info('[UnifuncsClient] Creating task', {
|
||||
model,
|
||||
queryLen: params.query.length,
|
||||
domainScope: payload.domain_scope,
|
||||
maxDepth: payload.max_depth,
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
*
|
||||
* 通过 OpenAI 兼容协议的 SSE 流获取实时 reasoning_content 和 content。
|
||||
* Worker 消费此流可实现逐条实时写入 executionLogs。
|
||||
*
|
||||
* 模型版本通过环境变量 UNIFUNCS_MODEL 控制,默认 s3。
|
||||
* 若 S3 出现问题,设置 UNIFUNCS_MODEL=s2 即可降级,无需改代码重部署。
|
||||
*/
|
||||
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
|
||||
const UNIFUNCS_BASE_URL = 'https://api.unifuncs.com/deepsearch/v1';
|
||||
const UNIFUNCS_API_KEY = process.env.UNIFUNCS_API_KEY;
|
||||
const UNIFUNCS_MODEL = process.env.UNIFUNCS_MODEL || 's3';
|
||||
|
||||
export interface SseStreamParams {
|
||||
query: string;
|
||||
@@ -34,8 +38,10 @@ export async function* streamDeepSearch(
|
||||
const apiKey = UNIFUNCS_API_KEY;
|
||||
if (!apiKey) throw new Error('UNIFUNCS_API_KEY not configured');
|
||||
|
||||
const model = UNIFUNCS_MODEL;
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
model: 's2',
|
||||
model,
|
||||
messages: [{ role: 'user', content: params.query }],
|
||||
stream: true,
|
||||
introduction: params.introduction || defaultIntroduction(),
|
||||
@@ -45,11 +51,16 @@ export async function* streamDeepSearch(
|
||||
reference_style: params.referenceStyle || 'link',
|
||||
};
|
||||
|
||||
if (model === 's3') {
|
||||
payload.language = 'zh';
|
||||
}
|
||||
|
||||
if (params.outputPrompt) {
|
||||
payload.output_prompt = params.outputPrompt;
|
||||
}
|
||||
|
||||
logger.info('[UnifuncsSse] Starting SSE stream', {
|
||||
model,
|
||||
queryLen: params.query.length,
|
||||
domainScope: payload.domain_scope,
|
||||
maxDepth: payload.max_depth,
|
||||
|
||||
@@ -311,6 +311,7 @@ export async function getTaskDetail(
|
||||
completedAt: task.completedAt,
|
||||
durationSeconds: task.durationSeconds,
|
||||
errorMessage: task.errorMessage,
|
||||
errorDetails: task.errorDetails ?? undefined,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
75
backend/src/modules/rvw/services/clinicalService.ts
Normal file
75
backend/src/modules/rvw/services/clinicalService.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* RVW稿件审查模块 - 临床专业评估服务
|
||||
* @module rvw/services/clinicalService
|
||||
*
|
||||
* 使用 PromptService 获取 RVW_CLINICAL prompt
|
||||
* 返回纯 Markdown 报告(无分数)
|
||||
*/
|
||||
|
||||
import { LLMFactory } from '../../../common/llm/adapters/LLMFactory.js';
|
||||
import { ModelType } from '../../../common/llm/adapters/types.js';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import { prisma } from '../../../config/database.js';
|
||||
import { getPromptService } from '../../../common/prompt/index.js';
|
||||
|
||||
export interface ClinicalReviewResult {
|
||||
report: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 临床专业评估
|
||||
* @param text 稿件文本
|
||||
* @param modelType 模型类型
|
||||
* @param userId 用户ID(用于灰度预览判断)
|
||||
* @returns 评估结果(Markdown 报告 + 摘要)
|
||||
*/
|
||||
export async function reviewClinical(
|
||||
text: string,
|
||||
modelType: ModelType = 'deepseek-v3',
|
||||
userId?: string
|
||||
): Promise<ClinicalReviewResult> {
|
||||
try {
|
||||
const promptService = getPromptService(prisma);
|
||||
const { content: systemPrompt, isDraft } = await promptService.get(
|
||||
'RVW_CLINICAL',
|
||||
{},
|
||||
{ userId }
|
||||
);
|
||||
|
||||
if (isDraft) {
|
||||
logger.info('[RVW:Clinical] 使用 DRAFT 版本 Prompt(调试模式)', { userId });
|
||||
}
|
||||
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: systemPrompt },
|
||||
{ role: 'user' as const, content: `请对以下医学稿件进行临床专业评估:\n\n${text}` },
|
||||
];
|
||||
|
||||
logger.info('[RVW:Clinical] 开始临床专业评估', { modelType });
|
||||
const llmAdapter = LLMFactory.getAdapter(modelType);
|
||||
const response = await llmAdapter.chat(messages, {
|
||||
temperature: 0.3,
|
||||
maxTokens: 8000,
|
||||
});
|
||||
const content = response.content ?? '';
|
||||
logger.info('[RVW:Clinical] 评估完成', {
|
||||
modelType,
|
||||
responseLength: content.length,
|
||||
});
|
||||
|
||||
// 提取摘要:取第一段非标题文本(最多200字)
|
||||
const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#'));
|
||||
const summary = lines.length > 0
|
||||
? lines[0].trim().substring(0, 200)
|
||||
: '临床专业评估已完成';
|
||||
|
||||
return { report: content, summary };
|
||||
} catch (error) {
|
||||
logger.error('[RVW:Clinical] 临床专业评估失败', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
throw new Error(`临床专业评估失败: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
@@ -207,7 +207,7 @@ export async function runReview(params: RunReviewParams): Promise<{ jobId: strin
|
||||
where: {
|
||||
id: taskId,
|
||||
// 只有当状态是 pending/completed/failed 时才允许启动
|
||||
status: { in: ['pending', 'completed', 'failed'] }
|
||||
status: { in: ['pending', 'completed', 'partial_completed', 'failed'] }
|
||||
},
|
||||
data: {
|
||||
status: 'reviewing',
|
||||
@@ -236,7 +236,7 @@ export async function runReview(params: RunReviewParams): Promise<{ jobId: strin
|
||||
agents,
|
||||
extractedText: task.extractedText,
|
||||
modelType: (task.modelUsed || 'deepseek-v3') as ModelType,
|
||||
__expireInSeconds: 10 * 60, // 10分钟超时(审稿任务通常2-3分钟完成)
|
||||
__expireInSeconds: 15 * 60, // 15min: 串行(5min)+并行(5min)+提取+余量,长文档可达8-10min
|
||||
});
|
||||
|
||||
logger.info('[RVW] 审查任务已推送到队列', {
|
||||
@@ -320,7 +320,7 @@ export async function getTaskList(params: TaskListParams): Promise<TaskListRespo
|
||||
if (status === 'pending') {
|
||||
where.status = { in: ['pending', 'extracting', 'reviewing', 'reviewing_editorial', 'reviewing_methodology'] };
|
||||
} else if (status === 'completed') {
|
||||
where.status = { in: ['completed', 'failed'] };
|
||||
where.status = { in: ['completed', 'partial_completed', 'failed'] };
|
||||
}
|
||||
|
||||
const [tasks, total] = await Promise.all([
|
||||
@@ -406,25 +406,26 @@ export async function getTaskReport(userId: string, taskId: string): Promise<Rev
|
||||
throw new Error('任务不存在或无权限访问');
|
||||
}
|
||||
|
||||
if (task.status !== 'completed') {
|
||||
if (task.status !== 'completed' && task.status !== 'partial_completed') {
|
||||
throw new Error(`报告尚未完成,当前状态: ${task.status}`);
|
||||
}
|
||||
|
||||
// 从 contextData 中提取 forensicsResult(V2.0 Skills 架构)
|
||||
const contextData = task.contextData as { forensicsResult?: unknown } | null;
|
||||
// 从 contextData 中提取 forensicsResult 和 clinicalReview(V2.0 Skills 架构)
|
||||
const contextData = task.contextData as { forensicsResult?: unknown; clinicalReview?: unknown } | null;
|
||||
const forensicsResult = contextData?.forensicsResult ?? undefined;
|
||||
const clinicalReview = contextData?.clinicalReview ?? undefined;
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
fileName: task.fileName,
|
||||
wordCount: task.wordCount ?? undefined,
|
||||
modelUsed: task.modelUsed ?? undefined,
|
||||
// 🆕 直接使用新字段
|
||||
selectedAgents: (task.selectedAgents || ['editorial', 'methodology']) as AgentType[],
|
||||
overallScore: task.overallScore ?? undefined,
|
||||
editorialReview: task.editorialReview as unknown as EditorialReview | undefined,
|
||||
methodologyReview: task.methodologyReview as unknown as MethodologyReview | undefined,
|
||||
forensicsResult: forensicsResult as ReviewReport['forensicsResult'],
|
||||
clinicalReview: clinicalReview as ReviewReport['clinicalReview'],
|
||||
completedAt: task.completedAt ?? undefined,
|
||||
durationSeconds: task.durationSeconds ?? undefined,
|
||||
};
|
||||
|
||||
@@ -105,15 +105,15 @@ export function validateAgentSelection(agents: string[]): void {
|
||||
throw new Error('请至少选择一个智能体');
|
||||
}
|
||||
|
||||
const validAgents = ['editorial', 'methodology'];
|
||||
const validAgents = ['editorial', 'methodology', 'clinical'];
|
||||
for (const agent of agents) {
|
||||
if (!validAgents.includes(agent)) {
|
||||
throw new Error(`无效的智能体类型: ${agent}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (agents.length > 2) {
|
||||
throw new Error('最多只能选择2个智能体');
|
||||
if (agents.length > 3) {
|
||||
throw new Error('最多只能选择3个智能体');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import { logger } from '../../../../common/logging/index.js';
|
||||
* 默认执行器配置
|
||||
*/
|
||||
const DEFAULT_EXECUTOR_CONFIG: ExecutorConfig = {
|
||||
defaultTimeout: 30000, // 30 秒
|
||||
defaultTimeout: 60000, // 60s 回退值(正常由 Profile 指定,此为安全兜底)
|
||||
maxRetries: 0,
|
||||
retryDelay: 1000,
|
||||
continueOnError: true,
|
||||
@@ -120,22 +120,50 @@ export class SkillExecutor<TContext extends BaseSkillContext = SkillContext> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 多个 Skill — 并行执行
|
||||
// 多个 Skill — 并行执行(使用 allSettled 保证故障隔离)
|
||||
logger.info('[SkillExecutor] Executing parallel stage', {
|
||||
taskId: context.taskId,
|
||||
skillIds: stage.map(s => s.skillId),
|
||||
});
|
||||
|
||||
const promises = stage.map(item => this.executePipelineItem(item, context, profile));
|
||||
const stageResults = await Promise.all(promises);
|
||||
const settled = await Promise.allSettled(promises);
|
||||
|
||||
for (let i = 0; i < stage.length; i++) {
|
||||
const result = stageResults[i];
|
||||
if (result) {
|
||||
results.push(result);
|
||||
context.previousResults.push(result);
|
||||
const skill = SkillRegistry.get(stage[i].skillId);
|
||||
if (skill) this.updateContextWithResult(context, skill, result);
|
||||
const outcome = settled[i];
|
||||
if (outcome.status === 'fulfilled') {
|
||||
const result = outcome.value;
|
||||
if (result) {
|
||||
results.push(result);
|
||||
context.previousResults.push(result);
|
||||
const skill = SkillRegistry.get(stage[i].skillId);
|
||||
if (skill) this.updateContextWithResult(context, skill, result);
|
||||
}
|
||||
} else {
|
||||
// Promise 本身 rejected — 极端情况下的兜底
|
||||
const errorMessage = outcome.reason instanceof Error
|
||||
? outcome.reason.message
|
||||
: String(outcome.reason);
|
||||
logger.error('[SkillExecutor] Parallel skill promise rejected (uncaught)', {
|
||||
skillId: stage[i].skillId,
|
||||
taskId: context.taskId,
|
||||
error: errorMessage,
|
||||
});
|
||||
const now = new Date();
|
||||
results.push({
|
||||
skillId: stage[i].skillId,
|
||||
skillName: stage[i].skillId,
|
||||
status: 'error',
|
||||
issues: [{
|
||||
severity: 'ERROR',
|
||||
type: SkillErrorCodes.SKILL_EXECUTION_ERROR,
|
||||
message: `${stage[i].skillId} 执行异常: ${errorMessage}`,
|
||||
}],
|
||||
error: errorMessage,
|
||||
executionTime: 0,
|
||||
startedAt: now,
|
||||
completedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,10 +390,13 @@ export class SkillExecutor<TContext extends BaseSkillContext = SkillContext> {
|
||||
const skippedCount = results.filter(r => r.status === 'skipped').length;
|
||||
const timeoutCount = results.filter(r => r.status === 'timeout').length;
|
||||
|
||||
// warning 表示"执行成功但发现数据问题",不是执行失败
|
||||
const completedCount = successCount + warningCount;
|
||||
|
||||
let overallStatus: 'success' | 'partial' | 'failed';
|
||||
if (errorCount === 0 && timeoutCount === 0) {
|
||||
overallStatus = 'success';
|
||||
} else if (successCount > 0) {
|
||||
} else if (completedCount > 0) {
|
||||
overallStatus = 'partial';
|
||||
} else {
|
||||
overallStatus = 'failed';
|
||||
|
||||
@@ -28,20 +28,27 @@ export const DEFAULT_PROFILE: JournalProfile = {
|
||||
checkLevel: 'L1_L2_L25',
|
||||
tolerancePercent: 0.1,
|
||||
},
|
||||
timeout: 60000,
|
||||
timeout: 300000, // 5min: Python + LLM核查(内部180s超时降级) + 长文档余量
|
||||
},
|
||||
{
|
||||
skillId: 'EditorialSkill',
|
||||
enabled: true,
|
||||
optional: false,
|
||||
timeout: 180000,
|
||||
timeout: 300000, // 5min: 20+页稿件 LLM 可能需要 2-3 分钟
|
||||
parallelGroup: 'llm-review',
|
||||
},
|
||||
{
|
||||
skillId: 'MethodologySkill',
|
||||
enabled: true,
|
||||
optional: false,
|
||||
timeout: 180000,
|
||||
timeout: 300000, // 5min: 方法学分析耗时最长
|
||||
parallelGroup: 'llm-review',
|
||||
},
|
||||
{
|
||||
skillId: 'ClinicalAssessmentSkill',
|
||||
enabled: true,
|
||||
optional: true,
|
||||
timeout: 300000, // 5min: 临床评估含 FINER 多维度分析
|
||||
parallelGroup: 'llm-review',
|
||||
},
|
||||
],
|
||||
@@ -71,7 +78,7 @@ export const CHINESE_CORE_PROFILE: JournalProfile = {
|
||||
checkLevel: 'L1_L2_L25',
|
||||
tolerancePercent: 0.05,
|
||||
},
|
||||
timeout: 60000,
|
||||
timeout: 300000,
|
||||
},
|
||||
{
|
||||
skillId: 'EditorialSkill',
|
||||
@@ -80,21 +87,21 @@ export const CHINESE_CORE_PROFILE: JournalProfile = {
|
||||
config: {
|
||||
standard: 'chinese-core',
|
||||
},
|
||||
timeout: 180000,
|
||||
timeout: 300000,
|
||||
parallelGroup: 'llm-review',
|
||||
},
|
||||
{
|
||||
skillId: 'MethodologySkill',
|
||||
enabled: true,
|
||||
optional: false,
|
||||
timeout: 180000,
|
||||
timeout: 300000,
|
||||
parallelGroup: 'llm-review',
|
||||
},
|
||||
],
|
||||
|
||||
globalConfig: {
|
||||
strictness: 'STRICT',
|
||||
continueOnError: false, // 严格模式,失败即停止
|
||||
continueOnError: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -116,7 +123,7 @@ export const QUICK_FORENSICS_PROFILE: JournalProfile = {
|
||||
checkLevel: 'L1_L2_L25',
|
||||
tolerancePercent: 0.1,
|
||||
},
|
||||
timeout: 60000,
|
||||
timeout: 300000,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -141,6 +148,7 @@ const PROFILES: Map<string, JournalProfile> = new Map([
|
||||
const AGENT_TO_SKILL_MAP: Record<string, string> = {
|
||||
'editorial': 'EditorialSkill',
|
||||
'methodology': 'MethodologySkill',
|
||||
'clinical': 'ClinicalAssessmentSkill',
|
||||
'forensics': 'DataForensicsSkill',
|
||||
};
|
||||
|
||||
|
||||
@@ -105,6 +105,8 @@ export interface ForensicsResult {
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
};
|
||||
llmReport?: string;
|
||||
llmTableReports?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* RVW V2.0 Skills 架构 - 临床专业评估 Skill
|
||||
*
|
||||
* 封装 clinicalService,对稿件进行临床研究设计评估
|
||||
*
|
||||
* @version 1.0.0
|
||||
* @since 2026-03-07
|
||||
*/
|
||||
|
||||
import { BaseSkill, ExecuteResult } from './BaseSkill.js';
|
||||
import {
|
||||
SkillMetadata,
|
||||
SkillContext,
|
||||
SkillConfig,
|
||||
} from '../core/types.js';
|
||||
import { reviewClinical, ClinicalReviewResult } from '../../services/clinicalService.js';
|
||||
import { logger } from '../../../../common/logging/index.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
const DEFAULT_MAX_CONTENT_LENGTH = 100000;
|
||||
|
||||
export const ClinicalConfigSchema = z.object({
|
||||
maxContentLength: z.number().default(100000),
|
||||
});
|
||||
export type ClinicalConfig = z.infer<typeof ClinicalConfigSchema>;
|
||||
|
||||
export class ClinicalAssessmentSkill extends BaseSkill<SkillContext, ClinicalConfig> {
|
||||
readonly configSchema = ClinicalConfigSchema;
|
||||
|
||||
readonly metadata: SkillMetadata = {
|
||||
id: 'ClinicalAssessmentSkill',
|
||||
name: '临床专业评估',
|
||||
description: '基于 FINER 标准对研究选题进行临床价值、创新性、科学性、可行性的系统评估',
|
||||
version: '1.0.0',
|
||||
category: 'methodology',
|
||||
|
||||
inputs: ['documentContent'],
|
||||
outputs: ['clinicalResult'],
|
||||
|
||||
defaultTimeout: 300000, // 5min: FINER 多维度评估,长文档需要更长
|
||||
retryable: true,
|
||||
|
||||
icon: '🏥',
|
||||
color: '#eb2f96',
|
||||
};
|
||||
|
||||
canRun(context: SkillContext): boolean {
|
||||
if (!context.documentContent || context.documentContent.trim().length === 0) {
|
||||
logger.warn('[ClinicalAssessmentSkill] No document content', { taskId: context.taskId });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context.documentContent.length > DEFAULT_MAX_CONTENT_LENGTH) {
|
||||
logger.warn('[ClinicalAssessmentSkill] Content too long', {
|
||||
taskId: context.taskId,
|
||||
contentLength: context.documentContent.length,
|
||||
limit: DEFAULT_MAX_CONTENT_LENGTH,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async execute(
|
||||
context: SkillContext,
|
||||
config?: ClinicalConfig
|
||||
): Promise<ExecuteResult> {
|
||||
const maxContentLength = config?.maxContentLength || DEFAULT_MAX_CONTENT_LENGTH;
|
||||
|
||||
logger.info('[ClinicalAssessmentSkill] Starting evaluation', {
|
||||
taskId: context.taskId,
|
||||
contentLength: context.documentContent.length,
|
||||
});
|
||||
|
||||
let content = context.documentContent;
|
||||
if (content.length > maxContentLength) {
|
||||
content = content.substring(0, maxContentLength);
|
||||
logger.warn('[ClinicalAssessmentSkill] Content truncated', {
|
||||
taskId: context.taskId,
|
||||
originalLength: context.documentContent.length,
|
||||
truncatedLength: maxContentLength,
|
||||
});
|
||||
}
|
||||
|
||||
const result: ClinicalReviewResult = await reviewClinical(content, 'deepseek-v3', context.userId);
|
||||
|
||||
logger.info('[ClinicalAssessmentSkill] Evaluation completed', {
|
||||
taskId: context.taskId,
|
||||
reportLength: result.report.length,
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
issues: [],
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const clinicalAssessmentSkill = new ClinicalAssessmentSkill();
|
||||
@@ -24,6 +24,9 @@ import {
|
||||
} from '../../../../common/document/ExtractionClient.js';
|
||||
import { storage } from '../../../../common/storage/index.js';
|
||||
import { logger } from '../../../../common/logging/index.js';
|
||||
import { LLMFactory } from '../../../../common/llm/adapters/LLMFactory.js';
|
||||
import { prisma } from '../../../../config/database.js';
|
||||
import { getPromptService } from '../../../../common/prompt/index.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
@@ -59,7 +62,7 @@ export class DataForensicsSkill extends BaseSkill<SkillContext, DataForensicsCon
|
||||
inputs: ['documentPath'],
|
||||
outputs: ['tables', 'methods', 'forensicsResult'],
|
||||
|
||||
defaultTimeout: 60000, // 60 秒
|
||||
defaultTimeout: 300000, // 5min: Python + LLM核查(内部180s超时降级) + 长文档余量
|
||||
retryable: true,
|
||||
|
||||
icon: '🐍',
|
||||
@@ -152,6 +155,21 @@ export class DataForensicsSkill extends BaseSkill<SkillContext, DataForensicsCon
|
||||
// 转换为内部格式
|
||||
const forensicsResult = this.convertResult(result);
|
||||
|
||||
// 3. LLM 智能核查(批量发送所有表格)
|
||||
if (forensicsResult.tables.length > 0) {
|
||||
try {
|
||||
await this.runLlmValidation(forensicsResult, context);
|
||||
} catch (llmError) {
|
||||
const errMsg = llmError instanceof Error ? llmError.message : String(llmError);
|
||||
const isTimeout = errMsg.includes('LLM_VALIDATION_TIMEOUT');
|
||||
logger.warn(`[DataForensicsSkill] LLM validation ${isTimeout ? 'timed out (180s)' : 'failed'}, falling back to rules only`, {
|
||||
taskId: context.taskId,
|
||||
error: errMsg,
|
||||
tableCount: forensicsResult.tables.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 计算状态和评分(基于数据质量结论,非执行状态;发现问题不等于执行失败)
|
||||
const hasErrors = forensicsResult.summary.errorCount > 0;
|
||||
const hasWarnings = forensicsResult.summary.warningCount > 0;
|
||||
@@ -176,6 +194,7 @@ export class DataForensicsSkill extends BaseSkill<SkillContext, DataForensicsCon
|
||||
issueCount: forensicsResult.summary.totalIssues,
|
||||
errorCount: forensicsResult.summary.errorCount,
|
||||
warningCount: forensicsResult.summary.warningCount,
|
||||
hasLlmReport: !!forensicsResult.llmReport,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -245,6 +264,82 @@ export class DataForensicsSkill extends BaseSkill<SkillContext, DataForensicsCon
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM 智能核查:将所有表格一次性发给 LLM 进行核查
|
||||
* 结果写入 forensicsResult.llmReport 和 forensicsResult.llmTableReports
|
||||
*/
|
||||
private async runLlmValidation(
|
||||
forensicsResult: ForensicsResult,
|
||||
context: SkillContext
|
||||
): Promise<void> {
|
||||
logger.info('[DataForensicsSkill] Starting LLM validation', {
|
||||
taskId: context.taskId,
|
||||
tableCount: forensicsResult.tables.length,
|
||||
});
|
||||
|
||||
const promptService = getPromptService(prisma);
|
||||
const { content: systemPrompt } = await promptService.get(
|
||||
'RVW_DATA_VALIDATION',
|
||||
{},
|
||||
{ userId: context.userId }
|
||||
);
|
||||
|
||||
// 拼接所有表格为 user message
|
||||
const tableTexts: string[] = [];
|
||||
for (let i = 0; i < forensicsResult.tables.length; i++) {
|
||||
const table = forensicsResult.tables[i];
|
||||
const caption = table.caption || `表格 ${i + 1}`;
|
||||
const dataText = (table.data || [])
|
||||
.map(row => row.join('\t'))
|
||||
.join('\n');
|
||||
tableTexts.push(`## 表${i + 1}: ${caption}\n\n${dataText}`);
|
||||
}
|
||||
|
||||
const userMessage = `以下是从医学科研稿件中提取的 ${forensicsResult.tables.length} 张表格,请逐表核查:\n\n${tableTexts.join('\n\n---\n\n')}`;
|
||||
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: systemPrompt },
|
||||
{ role: 'user' as const, content: userMessage },
|
||||
];
|
||||
|
||||
const llmAdapter = LLMFactory.getAdapter('deepseek-v3');
|
||||
|
||||
const LLM_TIMEOUT_MS = 180_000;
|
||||
const response = await Promise.race([
|
||||
llmAdapter.chat(messages, {
|
||||
temperature: 0.3,
|
||||
maxTokens: 8000,
|
||||
}),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('LLM_VALIDATION_TIMEOUT')), LLM_TIMEOUT_MS)
|
||||
),
|
||||
]);
|
||||
|
||||
const llmReport = response.content ?? '';
|
||||
|
||||
logger.info('[DataForensicsSkill] LLM validation completed', {
|
||||
taskId: context.taskId,
|
||||
reportLength: llmReport.length,
|
||||
tableCount: forensicsResult.tables.length,
|
||||
});
|
||||
|
||||
forensicsResult.llmReport = llmReport;
|
||||
|
||||
// 按 "## 表N:" 分割,映射到各表格
|
||||
forensicsResult.llmTableReports = {};
|
||||
const sections = llmReport.split(/(?=^## 表\d+[::])/m);
|
||||
for (const section of sections) {
|
||||
const headerMatch = section.match(/^## 表(\d+)[::]\s*(.*)/m);
|
||||
if (headerMatch) {
|
||||
const tableIdx = parseInt(headerMatch[1], 10) - 1;
|
||||
if (tableIdx >= 0 && tableIdx < forensicsResult.tables.length) {
|
||||
const tableId = forensicsResult.tables[tableIdx].id;
|
||||
forensicsResult.llmTableReports[tableId] = section.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换 Python 返回的结果为内部格式
|
||||
*
|
||||
|
||||
@@ -44,7 +44,7 @@ export class EditorialSkill extends BaseSkill<SkillContext, EditorialConfig> {
|
||||
inputs: ['documentContent'],
|
||||
outputs: ['editorialResult'],
|
||||
|
||||
defaultTimeout: 180000, // 180 秒(LLM 调用可能较慢)
|
||||
defaultTimeout: 300000, // 5min: 20+页长文档 LLM 处理需要更长时间
|
||||
retryable: true,
|
||||
|
||||
icon: '📋',
|
||||
|
||||
@@ -44,7 +44,7 @@ export class MethodologySkill extends BaseSkill<SkillContext, MethodologyConfig>
|
||||
inputs: ['documentContent', 'methods'],
|
||||
outputs: ['methodologyResult'],
|
||||
|
||||
defaultTimeout: 180000, // 180 秒(方法学分析需要更长时间)
|
||||
defaultTimeout: 300000, // 5min: 方法学分析最耗时,长文档可达 2-3 分钟
|
||||
retryable: true,
|
||||
|
||||
icon: '🔬',
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SkillRegistry } from '../core/registry.js';
|
||||
import { dataForensicsSkill, DataForensicsSkill } from './DataForensicsSkill.js';
|
||||
import { editorialSkill, EditorialSkill } from './EditorialSkill.js';
|
||||
import { methodologySkill, MethodologySkill } from './MethodologySkill.js';
|
||||
import { clinicalAssessmentSkill, ClinicalAssessmentSkill } from './ClinicalAssessmentSkill.js';
|
||||
|
||||
/**
|
||||
* 注册所有内置 Skills
|
||||
@@ -20,6 +21,7 @@ export function registerBuiltinSkills(): void {
|
||||
dataForensicsSkill,
|
||||
editorialSkill,
|
||||
methodologySkill,
|
||||
clinicalAssessmentSkill,
|
||||
]);
|
||||
|
||||
SkillRegistry.markInitialized();
|
||||
@@ -33,6 +35,7 @@ export function getBuiltinSkills() {
|
||||
dataForensicsSkill,
|
||||
editorialSkill,
|
||||
methodologySkill,
|
||||
clinicalAssessmentSkill,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -41,6 +44,7 @@ export {
|
||||
DataForensicsSkill,
|
||||
EditorialSkill,
|
||||
MethodologySkill,
|
||||
ClinicalAssessmentSkill,
|
||||
};
|
||||
|
||||
// 导出单例(用于直接调用)
|
||||
@@ -48,6 +52,7 @@ export {
|
||||
dataForensicsSkill,
|
||||
editorialSkill,
|
||||
methodologySkill,
|
||||
clinicalAssessmentSkill,
|
||||
};
|
||||
|
||||
// 导出基类
|
||||
|
||||
@@ -10,17 +10,18 @@
|
||||
* - editorial: 稿约规范性智能体
|
||||
* - methodology: 方法学统计智能体
|
||||
*/
|
||||
export type AgentType = 'editorial' | 'methodology';
|
||||
export type AgentType = 'editorial' | 'methodology' | 'clinical';
|
||||
|
||||
/**
|
||||
* 任务状态
|
||||
*/
|
||||
export type TaskStatus =
|
||||
| 'pending' // 待处理
|
||||
| 'extracting' // 正在提取文档
|
||||
| 'reviewing' // 正在评估
|
||||
| 'completed' // 已完成
|
||||
| 'failed'; // 失败
|
||||
| 'pending' // 待处理
|
||||
| 'extracting' // 正在提取文档
|
||||
| 'reviewing' // 正在评估
|
||||
| 'completed' // 已完成
|
||||
| 'partial_completed' // 部分完成(部分模块成功,部分失败/超时)
|
||||
| 'failed'; // 失败
|
||||
|
||||
/**
|
||||
* 方法学评估状态
|
||||
@@ -102,6 +103,8 @@ export interface ForensicsResult {
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
};
|
||||
llmReport?: string;
|
||||
llmTableReports?: Record<string, string>;
|
||||
}
|
||||
|
||||
// ==================== 请求参数 ====================
|
||||
@@ -172,6 +175,11 @@ export interface TaskListResponse {
|
||||
/**
|
||||
* 完整报告
|
||||
*/
|
||||
export interface ClinicalReviewResult {
|
||||
report: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface ReviewReport {
|
||||
taskId: string;
|
||||
fileName: string;
|
||||
@@ -182,6 +190,7 @@ export interface ReviewReport {
|
||||
editorialReview?: EditorialReview;
|
||||
methodologyReview?: MethodologyReview;
|
||||
forensicsResult?: ForensicsResult;
|
||||
clinicalReview?: ClinicalReviewResult;
|
||||
completedAt?: Date;
|
||||
durationSeconds?: number;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import { ModelType } from '../../../common/llm/adapters/types.js';
|
||||
import { reviewEditorialStandards } from '../services/editorialService.js';
|
||||
import { reviewMethodology } from '../services/methodologyService.js';
|
||||
import { calculateOverallScore, getMethodologyStatus } from '../services/utils.js';
|
||||
import type { AgentType, EditorialReview, MethodologyReview } from '../types/index.js';
|
||||
import type { AgentType, EditorialReview, MethodologyReview, ClinicalReviewResult } from '../types/index.js';
|
||||
import { activityService } from '../../../common/services/activity.service.js';
|
||||
|
||||
// V2.0 Skills 架构导入
|
||||
@@ -176,6 +176,7 @@ export async function registerReviewWorker() {
|
||||
// ========================================
|
||||
let editorialResult: EditorialReview | null = null;
|
||||
let methodologyResult: MethodologyReview | null = null;
|
||||
let clinicalResult: ClinicalReviewResult | null = null;
|
||||
let skillsSummary: ExecutionSummary | null = null;
|
||||
|
||||
if (USE_SKILLS_ARCHITECTURE) {
|
||||
@@ -195,6 +196,7 @@ export async function registerReviewWorker() {
|
||||
// 从 Skills 结果中提取兼容数据
|
||||
const editorialSkillResult = skillsSummary.results.find(r => r.skillId === 'EditorialSkill');
|
||||
const methodologySkillResult = skillsSummary.results.find(r => r.skillId === 'MethodologySkill');
|
||||
const clinicalSkillResult = skillsSummary.results.find(r => r.skillId === 'ClinicalAssessmentSkill');
|
||||
|
||||
if (editorialSkillResult?.status !== 'skipped' && editorialSkillResult?.data) {
|
||||
editorialResult = editorialSkillResult.data as EditorialReview;
|
||||
@@ -202,6 +204,9 @@ export async function registerReviewWorker() {
|
||||
if (methodologySkillResult?.status !== 'skipped' && methodologySkillResult?.data) {
|
||||
methodologyResult = methodologySkillResult.data as MethodologyReview;
|
||||
}
|
||||
if (clinicalSkillResult?.status !== 'skipped' && clinicalSkillResult?.data) {
|
||||
clinicalResult = clinicalSkillResult.data as ClinicalReviewResult;
|
||||
}
|
||||
|
||||
logger.info('[reviewWorker] Skills execution completed', {
|
||||
taskId,
|
||||
@@ -261,10 +266,32 @@ export async function registerReviewWorker() {
|
||||
const endTime = Date.now();
|
||||
const durationSeconds = Math.floor((endTime - startTime) / 1000);
|
||||
|
||||
// ========================================
|
||||
// 判断最终状态:completed / partial_completed / failed
|
||||
// ========================================
|
||||
let finalStatus: 'completed' | 'partial_completed' | 'failed' = 'completed';
|
||||
let errorDetails: Record<string, unknown> | null = null;
|
||||
|
||||
if (USE_SKILLS_ARCHITECTURE && skillsSummary) {
|
||||
if (skillsSummary.overallStatus === 'partial') {
|
||||
finalStatus = 'partial_completed';
|
||||
errorDetails = buildErrorDetails(skillsSummary);
|
||||
logger.warn('[reviewWorker] Partial completion detected', {
|
||||
taskId,
|
||||
successCount: skillsSummary.successCount,
|
||||
errorCount: skillsSummary.errorCount,
|
||||
timeoutCount: skillsSummary.timeoutCount,
|
||||
});
|
||||
} else if (skillsSummary.overallStatus === 'failed') {
|
||||
finalStatus = 'failed';
|
||||
errorDetails = buildErrorDetails(skillsSummary);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 更新任务结果
|
||||
// ========================================
|
||||
logger.info('[reviewWorker] Updating task result', { taskId });
|
||||
logger.info('[reviewWorker] Updating task result', { taskId, finalStatus });
|
||||
|
||||
// 构建 Skills 执行摘要(V2.0 新增,存储到专用 contextData 字段)
|
||||
const skillsContext = USE_SKILLS_ARCHITECTURE && skillsSummary
|
||||
@@ -279,13 +306,14 @@ export async function registerReviewWorker() {
|
||||
totalExecutionTime: skillsSummary.totalExecutionTime,
|
||||
},
|
||||
forensicsResult: skillsSummary.results.find(r => r.skillId === 'DataForensicsSkill')?.data,
|
||||
clinicalReview: clinicalResult,
|
||||
}
|
||||
: null;
|
||||
|
||||
await prisma.reviewTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: 'completed',
|
||||
status: finalStatus,
|
||||
editorialReview: editorialResult as unknown as Prisma.InputJsonValue ?? Prisma.JsonNull,
|
||||
methodologyReview: methodologyResult as unknown as Prisma.InputJsonValue ?? Prisma.JsonNull,
|
||||
contextData: skillsContext as unknown as Prisma.InputJsonValue ?? Prisma.JsonNull,
|
||||
@@ -295,6 +323,7 @@ export async function registerReviewWorker() {
|
||||
methodologyStatus: getMethodologyStatus(methodologyResult),
|
||||
completedAt: new Date(),
|
||||
durationSeconds,
|
||||
errorDetails: errorDetails as unknown as Prisma.InputJsonValue ?? Prisma.DbNull,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -302,6 +331,7 @@ export async function registerReviewWorker() {
|
||||
jobId: job.id,
|
||||
taskId,
|
||||
agents,
|
||||
finalStatus,
|
||||
editorialScore,
|
||||
methodologyScore,
|
||||
overallScore,
|
||||
@@ -309,7 +339,8 @@ export async function registerReviewWorker() {
|
||||
architecture: USE_SKILLS_ARCHITECTURE ? 'skills' : 'legacy',
|
||||
});
|
||||
|
||||
console.log('\n✅ 审查完成:');
|
||||
const statusEmoji = finalStatus === 'completed' ? '✅' : finalStatus === 'partial_completed' ? '⚠️' : '❌';
|
||||
console.log(`\n${statusEmoji} 审查完成 (${finalStatus}):`);
|
||||
console.log(` Task ID: ${taskId}`);
|
||||
console.log(` 综合得分: ${overallScore}`);
|
||||
console.log(` 耗时: ${durationSeconds}秒`);
|
||||
@@ -324,7 +355,7 @@ export async function registerReviewWorker() {
|
||||
});
|
||||
|
||||
if (user) {
|
||||
const agentNames = agents.map(a => a === 'editorial' ? '稿约规范性' : '方法学').join('+');
|
||||
const agentNames = agents.map(a => a === 'editorial' ? '稿约规范性' : a === 'methodology' ? '方法学' : '临床评估').join('+');
|
||||
activityService.log(
|
||||
user.tenant_id,
|
||||
user.tenants?.name || null,
|
||||
@@ -346,7 +377,8 @@ export async function registerReviewWorker() {
|
||||
editorialScore,
|
||||
methodologyScore,
|
||||
durationSeconds,
|
||||
success: true,
|
||||
success: finalStatus !== 'failed',
|
||||
status: finalStatus,
|
||||
architecture: USE_SKILLS_ARCHITECTURE ? 'skills' : 'legacy',
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
@@ -379,6 +411,34 @@ export async function registerReviewWorker() {
|
||||
logger.info('[reviewWorker] ✅ Worker registered: rvw_review_task');
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 errorDetails JSON,记录每个 Skill 的执行结果
|
||||
* 用于前端展示"哪个模块失败了、原因是什么"
|
||||
*/
|
||||
function buildErrorDetails(summary: ExecutionSummary): Record<string, unknown> {
|
||||
const skills: Record<string, unknown>[] = [];
|
||||
|
||||
for (const result of summary.results) {
|
||||
if (result.status === 'error' || result.status === 'timeout') {
|
||||
skills.push({
|
||||
skillId: result.skillId,
|
||||
skillName: result.skillName,
|
||||
status: result.status,
|
||||
error: result.error || (result.status === 'timeout' ? '执行超时' : '未知错误'),
|
||||
executionTime: result.executionTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
failedSkills: skills,
|
||||
successCount: summary.successCount + summary.warningCount,
|
||||
errorCount: summary.errorCount,
|
||||
timeoutCount: summary.timeoutCount ?? 0,
|
||||
totalSkills: summary.totalSkills,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 V2.0 Skills 架构执行审查
|
||||
*/
|
||||
|
||||
541
docs/03-业务模块/ASL-AI智能文献/03-UI设计/v6.html
Normal file
541
docs/03-业务模块/ASL-AI智能文献/03-UI设计/v6.html
Normal file
@@ -0,0 +1,541 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI智能文献平台 v6.0 (互斥手风琴版)</title>
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- Lucide Icons -->
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>
|
||||
.fade-in { animation: fadeIn 0.3s ease-out forwards; }
|
||||
.slide-in-from-bottom { animation: slideInFromBottom 0.3s ease-out forwards; }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes slideInFromBottom { from { transform: translateY(1rem); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||
|
||||
/* 隐藏滚动条但保留滚动功能 */
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
|
||||
/* * 核心黑科技:利用 CSS Grid 实现完美的柔性折叠动画
|
||||
* grid-rows-[0fr] 会将高度压缩到 0
|
||||
* grid-rows-[1fr] 会让高度填满 flex 分配的空间
|
||||
*/
|
||||
.accordion-grid {
|
||||
transition: grid-template-rows 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.accordion-content {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-50 font-sans text-slate-900 overflow-hidden h-screen flex flex-col">
|
||||
|
||||
<!-- 全局顶部导航 -->
|
||||
<header class="h-14 bg-slate-900 text-slate-300 flex items-center justify-between px-6 shadow-md z-30 shrink-0">
|
||||
<div class="flex items-center space-x-8">
|
||||
<div class="flex items-center space-x-2 text-white font-bold tracking-wide">
|
||||
<div class="bg-blue-600 p-1 rounded"><i data-lucide="activity" class="w-4 h-4 text-white"></i></div>
|
||||
<span>医学科研 AI 平台</span>
|
||||
</div>
|
||||
<nav class="flex space-x-1">
|
||||
<a href="#" class="px-4 py-2 text-sm font-medium hover:text-white transition rounded-md">AI 问答</a>
|
||||
<a href="#" class="px-4 py-2 text-sm font-medium text-white bg-slate-800 rounded-md shadow-inner flex items-center">
|
||||
<i data-lucide="book-open" class="w-4 h-4 mr-2 text-blue-400"></i> AI 文献
|
||||
</a>
|
||||
<a href="#" class="px-4 py-2 text-sm font-medium hover:text-white transition rounded-md">数据清洗</a>
|
||||
<a href="#" class="px-4 py-2 text-sm font-medium hover:text-white transition rounded-md">统计分析</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button class="text-slate-400 hover:text-white"><i data-lucide="bell" class="w-5 h-5"></i></button>
|
||||
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold text-sm">Dr</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
|
||||
<!-- ============================================== -->
|
||||
<!-- 核心重构 V6: 互斥手风琴侧边栏 (Mutually Exclusive Accordion) -->
|
||||
<!-- ============================================== -->
|
||||
<div class="w-72 bg-white border-r border-slate-200 flex flex-col z-20 shrink-0 shadow-sm relative">
|
||||
|
||||
<!-- 【面板 A】:智能文献检索 -->
|
||||
<div id="panel-search-wrapper" class="flex flex-col transition-all duration-400">
|
||||
<!-- Header A -->
|
||||
<button onclick="togglePanel('SEARCH')" class="flex items-center justify-between px-5 py-4 hover:bg-slate-50 transition-colors w-full group border-b border-transparent" id="header-search">
|
||||
<div class="flex items-center text-slate-800 font-bold text-[14px] transition-colors" id="title-search">
|
||||
<i data-lucide="sparkles" class="w-4 h-4 mr-2.5 text-indigo-500" id="icon-search"></i>
|
||||
智能文献检索
|
||||
</div>
|
||||
<i data-lucide="chevron-down" id="chevron-search" class="w-4 h-4 text-slate-400 transition-transform duration-400"></i>
|
||||
</button>
|
||||
|
||||
<!-- Content A (利用 Grid 实现流畅折叠) -->
|
||||
<div id="grid-search" class="grid accordion-grid grid-rows-[1fr]">
|
||||
<div class="accordion-content flex flex-col min-h-0 bg-white">
|
||||
<div class="p-4 pb-2 pt-0 shrink-0">
|
||||
<button onclick="createNewSearch()" class="w-full bg-indigo-600 text-white hover:bg-indigo-700 py-2.5 rounded-lg flex items-center justify-center text-sm font-semibold transition-colors shadow-sm shadow-indigo-200">
|
||||
<i data-lucide="plus" class="w-4 h-4 mr-1.5"></i> 新建智能检索
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-4 py-2 flex items-center justify-between text-xs font-bold text-slate-400 uppercase tracking-wider shrink-0">
|
||||
<span>最近检索历史</span>
|
||||
<i data-lucide="history" class="w-3.5 h-3.5"></i>
|
||||
</div>
|
||||
<!-- List A -->
|
||||
<div class="flex-1 overflow-y-auto px-3 pb-3 space-y-1 no-scrollbar" id="search-list-container">
|
||||
<!-- 由 JS 渲染 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 始终存在的分割线 -->
|
||||
<div class="h-px bg-slate-200 shrink-0 z-10 shadow-sm"></div>
|
||||
|
||||
<!-- 【面板 B】:系统综述项目 -->
|
||||
<div id="panel-project-wrapper" class="flex flex-col transition-all duration-400">
|
||||
<!-- Header B -->
|
||||
<button onclick="togglePanel('PROJECT')" class="flex items-center justify-between px-5 py-4 hover:bg-slate-50 transition-colors w-full group bg-slate-50/50" id="header-project">
|
||||
<div class="flex items-center text-slate-700 font-bold text-[14px] transition-colors" id="title-project">
|
||||
<i data-lucide="folder-kanban" class="w-4 h-4 mr-2.5 text-slate-500" id="icon-project"></i>
|
||||
系统综述项目 (SR)
|
||||
</div>
|
||||
<i data-lucide="chevron-up" id="chevron-project" class="w-4 h-4 text-slate-400 transition-transform duration-400"></i>
|
||||
</button>
|
||||
|
||||
<!-- Content B -->
|
||||
<div id="grid-project" class="grid accordion-grid grid-rows-[0fr]">
|
||||
<div class="accordion-content flex flex-col min-h-0 bg-slate-50">
|
||||
<div class="px-4 py-2 flex items-center justify-between text-xs font-bold text-slate-400 uppercase tracking-wider shrink-0 mt-1">
|
||||
<span>我的工作区</span>
|
||||
<i data-lucide="layout-grid" class="w-3.5 h-3.5"></i>
|
||||
</div>
|
||||
<!-- List B -->
|
||||
<div class="flex-1 overflow-y-auto px-3 pb-2 space-y-1 no-scrollbar" id="project-list-container">
|
||||
<!-- 由 JS 渲染项目列表 -->
|
||||
</div>
|
||||
<!-- 弱化设计的新建项目按钮 -->
|
||||
<div class="px-4 pb-4 shrink-0">
|
||||
<button onclick="createNewProject()" class="w-full border border-dashed border-slate-300 text-slate-500 hover:text-blue-600 hover:border-blue-300 hover:bg-blue-50 py-2 rounded-lg flex items-center justify-center text-[13px] font-medium transition-colors bg-white">
|
||||
<i data-lucide="plus" class="w-3.5 h-3.5 mr-1.5"></i> 创建新 SR 项目
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 侧边栏底部弹簧垫片,把剩余空间挤上去 -->
|
||||
<div class="flex-1 bg-slate-50"></div>
|
||||
|
||||
<!-- 底部配额信息 (固定在最底) -->
|
||||
<div class="p-4 border-t border-slate-200 bg-white shrink-0">
|
||||
<div class="text-[10px] text-slate-500 mb-1.5 font-medium flex justify-between uppercase tracking-wider">
|
||||
<span>DeepSearch 配额</span>
|
||||
<span>45k/100k</span>
|
||||
</div>
|
||||
<div class="w-full bg-slate-100 rounded-full h-1.5 overflow-hidden">
|
||||
<div class="bg-indigo-400 h-full rounded-full" style="width: 45%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="flex-1 flex flex-col overflow-hidden bg-slate-50 relative" id="main-content-container">
|
||||
<!-- 由 JS 渲染核心界面 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ==========================================
|
||||
// 1. 数据模型与状态 (Data Models & State)
|
||||
// ==========================================
|
||||
|
||||
let searchHistories = [
|
||||
{ id: 1, title: 'SGLT2 抑制剂对糖尿病的心血管影响', date: '今天', hasResult: true },
|
||||
{ id: 2, title: 'PD-1 联合化疗在非小细胞肺癌中的疗效', date: '昨天', hasResult: true },
|
||||
{ id: 3, title: 'Aspirin 一级预防最新 RCT', date: '前天', hasResult: true },
|
||||
{ id: 4, title: '阿兹海默症的最新靶向治疗方案综述', date: '本周', hasResult: true },
|
||||
{ id: 5, title: '肠道微生态与抑郁症发病机制的相关性研究', date: '本周', hasResult: true },
|
||||
{ id: 6, title: 'COVID-19 长新冠症状的系统性回顾', date: '更早', hasResult: true },
|
||||
{ id: 7, title: '间歇性禁食对代谢综合征的影响', date: '更早', hasResult: true },
|
||||
{ id: 8, title: 'CAR-T 细胞疗法在实体瘤中的进展', date: '更早', hasResult: true }
|
||||
];
|
||||
|
||||
let srProjects = [
|
||||
{ id: 1, title: 'SGLT2 抑制剂 Meta 分析', phase: 4, status: '进行中', count: 42, tiabConflictsResolved: true, metaRun: false },
|
||||
{ id: 2, title: '二甲双胍对认知功能的系统综述', phase: 2, status: '初筛中', count: 856, tiabConflictsResolved: false, metaRun: false },
|
||||
{ id: 3, title: 'CAR-T 治疗严重不良事件汇总', phase: 6, status: '已闭环', count: 18, tiabConflictsResolved: true, metaRun: true }
|
||||
];
|
||||
|
||||
const PHASES = [
|
||||
{ id: 1, name: '检索', icon: 'search' },
|
||||
{ id: 2, name: '初筛', icon: 'file-text' },
|
||||
{ id: 3, name: '复筛', icon: 'file-text' },
|
||||
{ id: 4, name: '提取/RoB', icon: 'database' },
|
||||
{ id: 5, name: 'Meta', icon: 'activity' },
|
||||
{ id: 6, name: '报告', icon: 'file-output' }
|
||||
];
|
||||
|
||||
// 核心全局状态 V6.0
|
||||
const state = {
|
||||
expandedPanel: 'SEARCH', // 核心变量:控制当前哪个抽屉打开 ('SEARCH' 或 'PROJECT')
|
||||
activeContext: 'SEARCH', // 核心变量:控制右侧主屏显示什么内容
|
||||
currentSearchId: 1,
|
||||
currentProjectId: null,
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// 2. 状态变更函数 (Actions)
|
||||
// ==========================================
|
||||
|
||||
// ⭐ 手风琴核心逻辑
|
||||
function togglePanel(panelName) {
|
||||
if (state.expandedPanel !== panelName) {
|
||||
state.expandedPanel = panelName;
|
||||
|
||||
// 联动:当用户点开某个抽屉时,如果右侧内容不属于该抽屉,自动切换右侧内容
|
||||
if (panelName === 'SEARCH' && state.activeContext !== 'SEARCH') {
|
||||
state.activeContext = 'SEARCH';
|
||||
if (!state.currentSearchId && searchHistories.length > 0) state.currentSearchId = searchHistories[0].id;
|
||||
} else if (panelName === 'PROJECT' && state.activeContext !== 'PROJECT') {
|
||||
state.activeContext = 'PROJECT';
|
||||
if (!state.currentProjectId && srProjects.length > 0) state.currentProjectId = srProjects[0].id;
|
||||
}
|
||||
|
||||
renderApp();
|
||||
}
|
||||
}
|
||||
|
||||
function selectSearch(id) {
|
||||
state.activeContext = 'SEARCH';
|
||||
state.currentSearchId = id;
|
||||
if (state.expandedPanel !== 'SEARCH') state.expandedPanel = 'SEARCH';
|
||||
renderApp();
|
||||
}
|
||||
|
||||
function selectProject(id) {
|
||||
state.activeContext = 'PROJECT';
|
||||
state.currentProjectId = id;
|
||||
if (state.expandedPanel !== 'PROJECT') state.expandedPanel = 'PROJECT';
|
||||
renderApp();
|
||||
}
|
||||
|
||||
function createNewSearch() {
|
||||
state.activeContext = 'SEARCH';
|
||||
state.currentSearchId = 'new';
|
||||
renderApp();
|
||||
}
|
||||
|
||||
function createNewProject() {
|
||||
state.activeContext = 'PROJECT';
|
||||
state.currentProjectId = 'new';
|
||||
renderApp();
|
||||
}
|
||||
|
||||
// ⭐ 转化逻辑:从左上角直接飞入左下角
|
||||
function convertToProject() {
|
||||
const sourceSearch = searchHistories.find(s => s.id === state.currentSearchId);
|
||||
const newProject = {
|
||||
id: Date.now(),
|
||||
title: `${sourceSearch ? sourceSearch.title : '未命名'} (AI 转化)`,
|
||||
phase: 2,
|
||||
status: '初筛中',
|
||||
count: 1250,
|
||||
tiabConflictsResolved: false,
|
||||
metaRun: false
|
||||
};
|
||||
srProjects.unshift(newProject);
|
||||
|
||||
// 切换上下文并强制展开下方抽屉,收起上方抽屉
|
||||
state.activeContext = 'PROJECT';
|
||||
state.currentProjectId = newProject.id;
|
||||
state.expandedPanel = 'PROJECT';
|
||||
renderApp();
|
||||
}
|
||||
|
||||
// 项目流程控制函数 (同前)
|
||||
function resolveProjectConflicts(projectId) {
|
||||
const p = srProjects.find(p => p.id === projectId);
|
||||
if(p) p.tiabConflictsResolved = true;
|
||||
renderApp();
|
||||
}
|
||||
function advanceProjectPhase(projectId, targetPhase) {
|
||||
const p = srProjects.find(p => p.id === projectId);
|
||||
if(p) { p.phase = targetPhase; p.status = targetPhase === 6 ? '已闭环' : '进行中'; }
|
||||
renderApp();
|
||||
}
|
||||
function runProjectMeta(projectId) {
|
||||
const p = srProjects.find(p => p.id === projectId);
|
||||
if(p) p.metaRun = true;
|
||||
renderApp();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 3. 视图渲染逻辑 (Views)
|
||||
// ==========================================
|
||||
|
||||
function renderSidebar() {
|
||||
const isSearchExpanded = state.expandedPanel === 'SEARCH';
|
||||
|
||||
// 1. 处理 CSS Grid 动画折叠
|
||||
const gridSearch = document.getElementById('grid-search');
|
||||
const gridProject = document.getElementById('grid-project');
|
||||
const panelSearchWrap = document.getElementById('panel-search-wrapper');
|
||||
const panelProjectWrap = document.getElementById('panel-project-wrapper');
|
||||
|
||||
if (isSearchExpanded) {
|
||||
// 展开上方,收起下方
|
||||
gridSearch.classList.replace('grid-rows-[0fr]', 'grid-rows-[1fr]');
|
||||
gridProject.classList.replace('grid-rows-[1fr]', 'grid-rows-[0fr]');
|
||||
panelSearchWrap.classList.replace('shrink-0', 'flex-1');
|
||||
panelSearchWrap.classList.replace('min-h-0', 'min-h-0'); // keep
|
||||
panelProjectWrap.classList.replace('flex-1', 'shrink-0');
|
||||
} else {
|
||||
// 收起上方,展开下方
|
||||
gridSearch.classList.replace('grid-rows-[1fr]', 'grid-rows-[0fr]');
|
||||
gridProject.classList.replace('grid-rows-[0fr]', 'grid-rows-[1fr]');
|
||||
panelSearchWrap.classList.replace('flex-1', 'shrink-0');
|
||||
panelProjectWrap.classList.replace('shrink-0', 'flex-1');
|
||||
panelProjectWrap.classList.replace('min-h-0', 'min-h-0'); // keep
|
||||
}
|
||||
|
||||
// 2. 处理 Header 的图标旋转与高亮
|
||||
const chevSearch = document.getElementById('chevron-search');
|
||||
const chevProject = document.getElementById('chevron-project');
|
||||
const titleSearch = document.getElementById('title-search');
|
||||
const titleProject = document.getElementById('title-project');
|
||||
const iconSearch = document.getElementById('icon-search');
|
||||
const iconProject = document.getElementById('icon-project');
|
||||
|
||||
if (isSearchExpanded) {
|
||||
chevSearch.classList.add('rotate-180');
|
||||
chevProject.classList.remove('rotate-180');
|
||||
titleSearch.classList.add('text-indigo-700'); titleSearch.classList.remove('text-slate-800');
|
||||
iconSearch.classList.replace('text-slate-400', 'text-indigo-500');
|
||||
titleProject.classList.replace('text-blue-700', 'text-slate-700');
|
||||
iconProject.classList.replace('text-blue-500', 'text-slate-500');
|
||||
} else {
|
||||
chevSearch.classList.remove('rotate-180');
|
||||
chevProject.classList.add('rotate-180');
|
||||
titleSearch.classList.remove('text-indigo-700'); titleSearch.classList.add('text-slate-800');
|
||||
iconSearch.classList.replace('text-indigo-500', 'text-slate-400');
|
||||
titleProject.classList.replace('text-slate-700', 'text-blue-700');
|
||||
iconProject.classList.replace('text-slate-500', 'text-blue-500');
|
||||
}
|
||||
|
||||
// 3. 渲染列表内部项目
|
||||
const searchContainer = document.getElementById('search-list-container');
|
||||
searchContainer.innerHTML = searchHistories.map(h => {
|
||||
const isActive = (state.activeContext === 'SEARCH' && state.currentSearchId === h.id);
|
||||
return `
|
||||
<div onclick="selectSearch(${h.id})" class="cursor-pointer group flex items-start p-2.5 rounded-lg transition-colors ${isActive ? 'bg-indigo-50 border border-indigo-100 shadow-sm' : 'hover:bg-slate-100 border border-transparent'}">
|
||||
<i data-lucide="message-square" class="w-4 h-4 mt-0.5 mr-2.5 shrink-0 ${isActive ? 'text-indigo-600' : 'text-slate-400 group-hover:text-slate-600'}"></i>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<h4 class="text-sm font-medium truncate ${isActive ? 'text-indigo-900' : 'text-slate-700'}">${h.title}</h4>
|
||||
<span class="text-[10px] text-slate-400 mt-0.5 block">${h.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const projectContainer = document.getElementById('project-list-container');
|
||||
projectContainer.innerHTML = srProjects.map(p => {
|
||||
const isActive = (state.activeContext === 'PROJECT' && state.currentProjectId === p.id);
|
||||
const statusColor = p.status === '已闭环' ? 'emerald' : p.status === '初筛中' ? 'amber' : 'blue';
|
||||
return `
|
||||
<div onclick="selectProject(${p.id})" class="cursor-pointer group flex items-start p-2.5 mb-1.5 rounded-lg transition-colors ${isActive ? 'bg-white border border-blue-200 shadow-sm ring-1 ring-blue-50' : 'hover:bg-white border border-transparent'}">
|
||||
<i data-lucide="folder" class="w-4 h-4 mt-0.5 mr-2.5 shrink-0 ${isActive ? 'text-blue-600' : 'text-slate-400 group-hover:text-blue-500'}"></i>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<h4 class="text-[13px] font-bold truncate ${isActive ? 'text-blue-900' : 'text-slate-600'}">${p.title}</h4>
|
||||
<div class="flex items-center mt-1.5 space-x-2">
|
||||
<span class="text-[10px] font-bold text-${statusColor}-700 bg-${statusColor}-100 px-1.5 rounded">${p.status}</span>
|
||||
<span class="text-[10px] font-mono text-slate-500">P${p.phase}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 主内容区渲染 (与 V5 逻辑一致)
|
||||
function renderMainContent() {
|
||||
const container = document.getElementById('main-content-container');
|
||||
|
||||
if (state.activeContext === 'SEARCH') {
|
||||
if (state.currentSearchId === 'new') {
|
||||
container.innerHTML = `
|
||||
<div class="flex-1 flex flex-col items-center justify-center p-8 fade-in h-full bg-white">
|
||||
<div class="w-16 h-16 bg-indigo-50 border border-indigo-100 rounded-2xl flex items-center justify-center mb-6 shadow-sm">
|
||||
<i data-lucide="sparkles" class="w-8 h-8 text-indigo-500"></i>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-slate-800 mb-2">探索医学前沿</h2>
|
||||
<p class="text-slate-500 mb-8 max-w-md text-center text-sm">输入您的研究问题或 PICOS 关键词,DeepSearch 将为您生成高质量综述报告与文献库。</p>
|
||||
|
||||
<div class="w-full max-w-2xl bg-white p-2 rounded-xl border border-slate-300 shadow-sm flex items-center focus-within:ring-2 ring-indigo-500/50 transition-all focus-within:border-indigo-500">
|
||||
<input type="text" class="flex-1 outline-none px-4 py-3 text-slate-700 placeholder-slate-400 bg-transparent" placeholder="例如:SGLT2 抑制剂对 2 型糖尿病的心血管结局影响...">
|
||||
<button class="bg-indigo-600 hover:bg-indigo-700 text-white p-3 rounded-lg shadow-sm transition">
|
||||
<i data-lucide="arrow-up" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
const searchInfo = searchHistories.find(s => s.id === state.currentSearchId) || searchHistories[0];
|
||||
container.innerHTML = `
|
||||
<div class="flex flex-col h-full fade-in bg-white">
|
||||
<header class="h-14 bg-white border-b border-slate-100 flex items-center px-6 shrink-0">
|
||||
<h1 class="text-sm font-bold text-slate-700 truncate">${searchInfo.title}</h1>
|
||||
<span class="ml-3 text-[10px] font-medium text-slate-500 bg-slate-100 px-2 py-1 rounded-md border border-slate-200 flex items-center"><i data-lucide="layers" class="w-3 h-3 mr-1"></i>1250 篇</span>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6 md:p-8 space-y-8 pb-32">
|
||||
<div class="max-w-4xl mx-auto flex space-x-4">
|
||||
<div class="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center shrink-0 mt-1"><span class="text-slate-600 text-xs font-bold">Dr</span></div>
|
||||
<div class="flex-1"><div class="inline-block bg-slate-100 px-5 py-3.5 rounded-2xl rounded-tl-none text-slate-800 text-sm shadow-sm">帮我查一下相关文献,要求是近5年的英文随机对照试验(RCT)。</div></div>
|
||||
</div>
|
||||
<div class="max-w-4xl mx-auto flex space-x-4">
|
||||
<div class="w-8 h-8 rounded-full bg-indigo-600 flex items-center justify-center shrink-0 mt-1 shadow-md shadow-indigo-200"><i data-lucide="bot" class="w-4 h-4 text-white"></i></div>
|
||||
<div class="flex-1">
|
||||
<div class="bg-white border border-slate-200 rounded-2xl p-6 shadow-sm">
|
||||
<h4 class="font-bold text-slate-800 flex items-center mb-3">
|
||||
<i data-lucide="check-circle-2" class="w-5 h-5 text-emerald-500 mr-2"></i> 智能检索完成:获取有效文献 1,250 篇
|
||||
</h4>
|
||||
<p class="text-sm text-slate-600 leading-relaxed mb-5">
|
||||
基于您的查询,我从 PubMed 和 Cochrane Library 汇总了最新高质量研究。主要发现包括:目标干预不仅能有效改善主要结局指标,还在多个大型试验中显著降低了不良事件风险。
|
||||
</p>
|
||||
<div class="mt-4 p-5 bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-100 rounded-xl flex items-center justify-between">
|
||||
<div>
|
||||
<h5 class="font-bold text-blue-900 text-sm flex items-center"><i data-lucide="workflow" class="w-4 h-4 mr-1.5 text-blue-600"></i> 需要进行严格的系统综述吗?</h5>
|
||||
<p class="text-xs text-blue-700 mt-1.5 opacity-80">将这 1,250 篇文献转入流水线,即可启动自动化双盲初筛。</p>
|
||||
</div>
|
||||
<button onclick="convertToProject()" class="bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium py-2 px-5 rounded-lg shadow-sm shadow-blue-200 transition flex items-center shrink-0">
|
||||
转入 SR 项目流水线 <i data-lucide="arrow-right" class="w-4 h-4 ml-1.5"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-white via-white to-transparent p-6 pb-8">
|
||||
<div class="max-w-4xl mx-auto bg-white p-1.5 rounded-xl border border-slate-300 shadow-lg shadow-slate-200/50 flex items-center">
|
||||
<input type="text" class="flex-1 outline-none px-4 py-2 text-sm text-slate-700 bg-transparent" placeholder="追问检索结果,或修改条件...">
|
||||
<button class="bg-indigo-600 text-white p-2.5 rounded-lg shadow-sm"><i data-lucide="arrow-up" class="w-4 h-4"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} else if (state.activeContext === 'PROJECT') {
|
||||
if (state.currentProjectId === 'new') {
|
||||
container.innerHTML = `
|
||||
<div class="flex-1 flex flex-col items-center justify-center p-8 fade-in h-full bg-slate-50">
|
||||
<div class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mb-6 shadow-sm border border-blue-200">
|
||||
<i data-lucide="folder-plus" class="w-8 h-8 text-blue-600"></i>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-slate-800 mb-2">创建系统综述项目 (SR)</h2>
|
||||
<p class="text-slate-500 mb-8 max-w-md text-center text-sm">建立严谨的瀑布流科研管线。支持通过 DeepSearch 抓取或本地导入文献库。</p>
|
||||
|
||||
<div class="w-full max-w-lg bg-white p-7 rounded-2xl border border-slate-200 shadow-sm space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-slate-700 mb-2">项目名称</label>
|
||||
<input type="text" class="w-full border border-slate-300 rounded-lg px-4 py-2.5 text-sm focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none transition-all bg-slate-50 hover:bg-white" placeholder="例如:某某药物的疗效与安全性 Meta 分析">
|
||||
</div>
|
||||
<div class="pt-4 flex justify-end space-x-3 border-t border-slate-100">
|
||||
<button class="px-5 py-2.5 text-sm font-medium text-slate-600 hover:bg-slate-100 rounded-lg transition-colors">取消</button>
|
||||
<button class="px-5 py-2.5 text-sm font-bold text-white bg-blue-600 hover:bg-blue-700 rounded-lg shadow-sm shadow-blue-200 transition-colors">创建并配置方案</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
const project = srProjects.find(p => p.id === state.currentProjectId) || srProjects[0];
|
||||
container.innerHTML = `
|
||||
<div class="flex flex-col h-full fade-in bg-slate-50/50">
|
||||
<header class="h-14 bg-white border-b border-slate-200 flex items-center justify-between px-6 shadow-sm shrink-0">
|
||||
<div class="flex items-center">
|
||||
<div class="bg-blue-100 p-1 rounded mr-3"><i data-lucide="folder-kanban" class="w-4 h-4 text-blue-600"></i></div>
|
||||
<h1 class="text-sm font-bold text-slate-800">${project.title}</h1>
|
||||
</div>
|
||||
<span class="text-[11px] font-bold text-slate-500 bg-slate-100 px-2.5 py-1 rounded-md border border-slate-200 tracking-wide uppercase">Workspace</span>
|
||||
</header>
|
||||
|
||||
<!-- 瀑布流步进器 Stepper -->
|
||||
<div class="bg-white border-b border-slate-200 px-8 py-3 shadow-sm shrink-0 overflow-x-auto">
|
||||
<div class="flex items-center justify-between min-w-max">
|
||||
${PHASES.map((phase, index) => {
|
||||
const isActive = project.phase === phase.id;
|
||||
const isPast = project.phase > phase.id;
|
||||
const circleClasses = isActive ? 'border-blue-600 bg-blue-50 text-blue-600 shadow-sm ring-2 ring-blue-100'
|
||||
: isPast ? 'border-emerald-500 bg-emerald-500 text-white'
|
||||
: 'border-slate-300 bg-slate-50 text-slate-400';
|
||||
const textClasses = isActive ? 'text-blue-800 font-bold' : isPast ? 'text-slate-700' : 'text-slate-400 font-medium';
|
||||
const lineClasses = isPast ? 'bg-emerald-500' : 'bg-slate-200';
|
||||
|
||||
return `
|
||||
<div class="flex items-center cursor-pointer group" onclick="advanceProjectPhase(${project.id}, ${phase.id})">
|
||||
<div class="flex items-center justify-center w-7 h-7 rounded-full border-2 transition-all ${circleClasses}">
|
||||
${isPast ? `<i data-lucide="check" class="w-3.5 h-3.5"></i>` : `<i data-lucide="${phase.icon}" class="w-3 h-3"></i>`}
|
||||
</div>
|
||||
<span class="ml-2.5 text-[13px] transition-colors ${textClasses} group-hover:text-blue-600">${phase.name}</span>
|
||||
${index < PHASES.length - 1 ? `<div class="w-8 h-[2px] mx-4 transition-colors ${lineClasses}"></div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="flex-1 overflow-y-auto p-8" id="sr-step-content">
|
||||
${renderProjectPhaseContent(project)}
|
||||
</main>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderProjectPhaseContent(project) {
|
||||
const SectionHeader = (title, desc) => `
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold text-slate-800">${title}</h2>
|
||||
<p class="text-sm text-slate-500 mt-1.5">${desc}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
switch(project.phase) {
|
||||
case 1: return `<div class="max-w-4xl mx-auto fade-in">${SectionHeader("Step 1: 选题与检索数据源", "准备阶段...")}<div class="bg-white p-10 rounded-2xl border border-slate-200 text-center shadow-sm"><button onclick="advanceProjectPhase(${project.id}, 2)" class="bg-blue-600 text-white px-5 py-2.5 rounded-lg text-sm font-medium hover:bg-blue-700 shadow-sm">模拟导入完成,进入初筛</button></div></div>`;
|
||||
case 2:
|
||||
let conflictUI = !project.tiabConflictsResolved
|
||||
? `<div class="bg-amber-50 border border-amber-200 rounded-xl p-6 mb-6"><h4 class="font-bold text-amber-800 text-sm flex items-center mb-2"><i data-lucide="alert-triangle" class="w-4 h-4 mr-2"></i> 必须解决冲突</h4><div class="bg-white rounded-lg border border-amber-100 p-4 flex justify-between items-center shadow-sm"><span class="text-sm font-medium text-slate-700">冲突记录: Example Paper 2024</span><button onclick="resolveProjectConflicts(${project.id})" class="text-xs font-bold bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 shadow-sm">一键解决</button></div></div>`
|
||||
: `<div class="bg-emerald-50 border border-emerald-200 rounded-xl p-6 mb-6 flex items-center"><i data-lucide="check-circle" class="w-6 h-6 text-emerald-500 mr-3"></i><div><h4 class="font-bold text-emerald-800 text-sm">初筛已完成</h4></div></div>`;
|
||||
let nxtBtn = !project.tiabConflictsResolved
|
||||
? `<button disabled class="px-6 py-2.5 rounded-lg bg-slate-200 text-slate-400 text-sm font-medium cursor-not-allowed">进入复筛</button>`
|
||||
: `<button onclick="advanceProjectPhase(${project.id}, 3)" class="bg-blue-600 text-white px-6 py-2.5 rounded-lg text-sm font-bold shadow-sm hover:bg-blue-700">进入复筛</button>`;
|
||||
return `<div class="max-w-4xl mx-auto fade-in">${SectionHeader("Step 2: 标题摘要初筛", "双盲初筛流水线。")}${conflictUI}<div class="flex justify-end">${nxtBtn}</div></div>`;
|
||||
case 3:
|
||||
case 4:
|
||||
case 5:
|
||||
let btnText = project.phase === 3 ? '进入数据提取' : project.phase === 4 ? '进入Meta分析' : '生成最终报告';
|
||||
return `<div class="max-w-4xl mx-auto fade-in">${SectionHeader(`Step ${project.phase}: ${PHASES[project.phase-1].name}`, "专业操作区。")}<div class="bg-white p-16 text-center rounded-2xl border border-slate-200 mb-6 shadow-sm"><i data-lucide="${PHASES[project.phase-1].icon}" class="w-12 h-12 text-slate-300 mx-auto mb-4"></i><p class="text-slate-500 text-sm font-medium">这里是 ${PHASES[project.phase-1].name} 的详细工作台区域。</p></div><div class="flex justify-end"><button onclick="advanceProjectPhase(${project.id}, ${project.phase + 1})" class="bg-slate-800 text-white px-6 py-2.5 rounded-lg text-sm font-bold flex items-center shadow-sm hover:bg-slate-700">${btnText} <i data-lucide="arrow-right" class="w-4 h-4 ml-2"></i></button></div></div>`;
|
||||
case 6:
|
||||
return `<div class="max-w-4xl mx-auto fade-in">${SectionHeader("Step 6: 报告生成", "项目成果。")}<div class="bg-white border border-slate-200 rounded-2xl p-12 flex flex-col items-center text-center shadow-sm"><div class="bg-emerald-50 p-4 rounded-full border border-emerald-100 mb-4"><i data-lucide="check-circle" class="w-12 h-12 text-emerald-500"></i></div><h2 class="text-2xl font-black text-slate-800">流程闭环完成</h2><button class="mt-8 bg-blue-600 text-white px-6 py-3 rounded-xl shadow-md text-sm font-bold flex items-center hover:bg-blue-700 transition"><i data-lucide="download" class="w-4 h-4 mr-2"></i> 下载 PRISMA & Word 报告</button></div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 4. 初始化引擎
|
||||
// ==========================================
|
||||
function renderApp() {
|
||||
renderSidebar();
|
||||
renderMainContent();
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
renderApp();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 移除评分机制 + 单模块重试
|
||||
|
||||
@@ -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`,无需重新部署代码 |
|
||||
|
||||
### 基础设施变更
|
||||
|
||||
|
||||
197
docs/05-部署文档/0305部署/01-部署完成总结.md
Normal file
197
docs/05-部署文档/0305部署/01-部署完成总结.md
Normal file
@@ -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
|
||||
> **维护人员**:开发团队
|
||||
@@ -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<ApiResponse<DeepResearchTaskSummary[]>> {
|
||||
return request('/research/v2/tasks');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 V2.0 检索任务
|
||||
*/
|
||||
export async function deleteDeepResearchTask(taskId: string): Promise<ApiResponse<void>> {
|
||||
return request(`/research/tasks/${taskId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 V2.0 任务详情(状态 + 日志 + 结果)
|
||||
*/
|
||||
@@ -714,6 +729,8 @@ export const aslApi = {
|
||||
getDeepResearchDataSources,
|
||||
generateRequirement,
|
||||
executeDeepResearchTask,
|
||||
listDeepResearchTasks,
|
||||
deleteDeepResearchTask,
|
||||
getDeepResearchTask,
|
||||
|
||||
// 工具 3:全文智能提取
|
||||
|
||||
@@ -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<MenuProps>['items'][number];
|
||||
interface ASLLayoutContextValue {
|
||||
refreshHistory: () => void;
|
||||
}
|
||||
|
||||
const ASLLayoutContext = createContext<ASLLayoutContextValue>({
|
||||
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: <SearchOutlined />,
|
||||
label: '1. 智能文献检索',
|
||||
},
|
||||
{
|
||||
key: 'title-screening',
|
||||
icon: <FilterOutlined />,
|
||||
label: '2. 标题摘要初筛',
|
||||
children: [
|
||||
{
|
||||
key: '/literature/screening/title/settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: '设置与启动',
|
||||
},
|
||||
{
|
||||
key: '/literature/screening/title/workbench',
|
||||
icon: <CheckSquareOutlined />,
|
||||
label: '审核工作台',
|
||||
},
|
||||
{
|
||||
key: '/literature/screening/title/results',
|
||||
icon: <UnorderedListOutlined />,
|
||||
label: '初筛结果',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'extraction',
|
||||
icon: <DatabaseOutlined />,
|
||||
label: '3. 全文智能提取',
|
||||
children: [
|
||||
{
|
||||
key: '/literature/extraction/setup',
|
||||
icon: <SettingOutlined />,
|
||||
label: '配置与启动',
|
||||
},
|
||||
{
|
||||
key: '/literature/extraction/workbench',
|
||||
icon: <CheckSquareOutlined />,
|
||||
label: '审核工作台',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: '/literature/charting',
|
||||
icon: <ApartmentOutlined />,
|
||||
label: '4. SR 图表生成器',
|
||||
},
|
||||
{
|
||||
key: '/literature/meta-analysis',
|
||||
icon: <BarChartOutlined />,
|
||||
label: '5. Meta 分析引擎',
|
||||
},
|
||||
];
|
||||
const [expandedPanel, setExpandedPanel] = useState<PanelType>('SEARCH');
|
||||
const [deletingId, setDeletingId] = useState<string | null>(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 (
|
||||
<Layout className="h-screen">
|
||||
{/* 左侧导航栏 */}
|
||||
<Sider
|
||||
width={250}
|
||||
className="bg-gray-50 border-r border-gray-200"
|
||||
theme="light"
|
||||
>
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-bold text-gray-800">
|
||||
<FilterOutlined className="mr-2" />
|
||||
AI智能文献
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
标题摘要初筛 MVP
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={selectedKeys}
|
||||
defaultOpenKeys={openKeys}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
className="border-none"
|
||||
style={{ height: 'calc(100vh - 80px)', overflowY: 'auto' }}
|
||||
/>
|
||||
</Sider>
|
||||
<ASLLayoutContext.Provider value={contextValue}>
|
||||
<div style={{ height: '100vh', display: 'flex', overflow: 'hidden' }}>
|
||||
{/* ── 侧边栏 ── */}
|
||||
<div className="asl-sidebar">
|
||||
|
||||
{/* 右侧内容区 */}
|
||||
<Layout>
|
||||
<Content className={isResearchPage ? "overflow-auto" : "bg-white overflow-auto"}>
|
||||
{/* ── 面板 A Header ── */}
|
||||
<div
|
||||
className="asl-panel-header"
|
||||
onClick={() => togglePanel('SEARCH')}
|
||||
>
|
||||
<div className={`asl-panel-header-left ${isSearchExpanded ? 'active' : ''}`}>
|
||||
<Sparkles size={16} className="header-icon" />
|
||||
<span>智能文献检索</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`asl-panel-chevron ${isSearchExpanded ? 'expanded' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 面板 A Content ── */}
|
||||
<div className={`asl-panel-content ${isSearchExpanded ? '' : 'collapsed'}`}>
|
||||
<button className="asl-new-btn" onClick={handleNewSearch}>
|
||||
<Plus size={16} />
|
||||
新建智能检索
|
||||
</button>
|
||||
|
||||
<div className="asl-list-section-label">
|
||||
<span>最近检索历史</span>
|
||||
<History size={14} />
|
||||
</div>
|
||||
|
||||
<div className="asl-history-list">
|
||||
{historyTasks.length === 0 ? (
|
||||
<div className="asl-empty-state">暂无检索记录</div>
|
||||
) : (
|
||||
groupedHistory.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="asl-date-group-label">{group.label}</div>
|
||||
{group.items.map((task) => {
|
||||
const isActive = currentTaskId === task.id;
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`asl-conv-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => selectTask(task.id)}
|
||||
>
|
||||
<MessageSquare size={16} className="conv-icon" />
|
||||
<span
|
||||
className={`asl-conv-status ${task.status}`}
|
||||
/>
|
||||
<span className="asl-conv-title">{task.query}</span>
|
||||
<Popconfirm
|
||||
title="确认删除此检索记录?"
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation();
|
||||
handleDelete(task.id);
|
||||
}}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<button
|
||||
className="asl-conv-delete"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={deletingId === task.id}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 分割线 ── */}
|
||||
<div className="asl-divider" />
|
||||
|
||||
{/* ── 面板 B Header ── */}
|
||||
<div
|
||||
className="asl-panel-header"
|
||||
onClick={() => togglePanel('PROJECT')}
|
||||
>
|
||||
<div className={`asl-panel-header-left ${!isSearchExpanded ? 'active' : ''}`}>
|
||||
<FolderKanban size={16} className="header-icon" />
|
||||
<span>系统综述项目 (SR)</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`asl-panel-chevron ${!isSearchExpanded ? 'expanded' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 面板 B Content ── */}
|
||||
<div className={`asl-panel-content ${!isSearchExpanded ? '' : 'collapsed'}`}>
|
||||
{hasSR ? (
|
||||
<div className="asl-nav-list">
|
||||
{SR_NAV_ITEMS.map((item) => {
|
||||
const isActive = location.pathname.startsWith(item.matchPrefix);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={item.path}
|
||||
className={`asl-nav-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => navigate(item.path)}
|
||||
>
|
||||
<Icon size={16} className="nav-icon" />
|
||||
<span className="asl-nav-item-label">{item.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="asl-sr-locked">
|
||||
<Lock size={32} className="asl-sr-locked-icon" />
|
||||
<p className="asl-sr-locked-text">请联系管理员开通系统综述功能</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 右侧内容区 ── */}
|
||||
<div style={{ flex: 1, overflow: 'hidden', background: '#F9FAFB' }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</div>
|
||||
</div>
|
||||
</ASLLayoutContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ASLLayout;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -24,9 +24,10 @@ const { Text } = Typography;
|
||||
interface ResultsViewProps {
|
||||
task: DeepResearchTask;
|
||||
onNewSearch: () => void;
|
||||
hideBanner?: boolean;
|
||||
}
|
||||
|
||||
const ResultsView: React.FC<ResultsViewProps> = ({ task, onNewSearch }) => {
|
||||
const ResultsView: React.FC<ResultsViewProps> = ({ task, onNewSearch, hideBanner }) => {
|
||||
const { synthesisReport, resultList, resultCount, query, completedAt, taskId } = task;
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
@@ -129,28 +130,30 @@ const ResultsView: React.FC<ResultsViewProps> = ({ task, onNewSearch }) => {
|
||||
return (
|
||||
<div>
|
||||
{/* 完成横幅 */}
|
||||
<Card className="mb-6 !bg-green-50 !border-green-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircleFilled className="text-2xl text-green-500" />
|
||||
<div>
|
||||
<Text strong className="text-lg block">Deep Research 完成</Text>
|
||||
<Text type="secondary" className="text-sm">
|
||||
「{query}」 — 找到 {resultCount || 0} 篇文献
|
||||
{completedAt && ` · ${new Date(completedAt).toLocaleString()}`}
|
||||
</Text>
|
||||
{!hideBanner && (
|
||||
<Card className="mb-6 !bg-green-50 !border-green-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircleFilled className="text-2xl text-green-500" />
|
||||
<div>
|
||||
<Text strong className="text-lg block">Deep Research 完成</Text>
|
||||
<Text type="secondary" className="text-sm">
|
||||
「{query}」 — 找到 {resultCount || 0} 篇文献
|
||||
{completedAt && ` · ${new Date(completedAt).toLocaleString()}`}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button icon={<DownloadOutlined />} loading={exporting} onClick={handleExportWord}>
|
||||
导出 Word
|
||||
</Button>
|
||||
<Button icon={<PlusOutlined />} onClick={onNewSearch}>
|
||||
新建检索
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button icon={<DownloadOutlined />} loading={exporting} onClick={handleExportWord}>
|
||||
导出 Word
|
||||
</Button>
|
||||
<Button icon={<PlusOutlined />} onClick={onNewSearch}>
|
||||
新建检索
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* AI 综合报告 — Markdown 渲染 */}
|
||||
{synthesisReport && (
|
||||
|
||||
@@ -24,6 +24,7 @@ interface StrategyConfirmProps {
|
||||
intentSummary: IntentSummary | null;
|
||||
onConfirm: (confirmedRequirement: string) => void;
|
||||
collapsed?: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const StrategyConfirm: React.FC<StrategyConfirmProps> = ({
|
||||
@@ -31,11 +32,13 @@ const StrategyConfirm: React.FC<StrategyConfirmProps> = ({
|
||||
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 (
|
||||
<Card size="small" className="!bg-white">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -46,6 +49,64 @@ const StrategyConfirm: React.FC<StrategyConfirmProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
className="!bg-white"
|
||||
title={
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer select-none"
|
||||
onClick={() => setExpanded(prev => !prev)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircleOutlined className="text-green-500" />
|
||||
<span>检索需求书</span>
|
||||
</div>
|
||||
<Text type="secondary" className="text-xs font-normal">
|
||||
{expanded ? '收起' : '展开详情'}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{intentSummary && (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AimOutlined className="text-blue-500" />
|
||||
<Text className="text-sm">{intentSummary.objective}</Text>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs">
|
||||
<span><Text type="secondary">P 人群</Text> <Text className="ml-1">{intentSummary.population || '—'}</Text></span>
|
||||
<span><Text type="secondary">I 干预</Text> <Text className="ml-1">{intentSummary.intervention || '—'}</Text></span>
|
||||
<span><Text type="secondary">C 对照</Text> <Text className="ml-1">{intentSummary.comparison || '—'}</Text></span>
|
||||
<span><Text type="secondary">O 结局</Text> <Text className="ml-1">{intentSummary.outcome || '—'}</Text></span>
|
||||
</div>
|
||||
{intentSummary.meshTerms && intentSummary.meshTerms.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap mt-2">
|
||||
<TagsOutlined className="text-green-500 text-xs" />
|
||||
{intentSummary.meshTerms.map((term, i) => (
|
||||
<Tag key={i} color="green" className="!text-xs !m-0">{term}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{intentSummary.studyDesign && intentSummary.studyDesign.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap mt-1">
|
||||
{intentSummary.studyDesign.map((s, i) => (
|
||||
<Tag key={i} color="blue" className="!text-xs !m-0">{s}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{expanded && generatedRequirement && (
|
||||
<div className="mt-2 p-3 bg-gray-50 rounded-lg text-sm leading-relaxed whitespace-pre-wrap text-gray-700 max-h-[400px] overflow-y-auto">
|
||||
{generatedRequirement}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
setSaved(true);
|
||||
message.success('检索指令已保存');
|
||||
|
||||
@@ -71,6 +71,7 @@ const ASLModule = () => {
|
||||
|
||||
{/* Deep Research V2.0 */}
|
||||
<Route path="research/deep" element={<DeepResearchPage />} />
|
||||
<Route path="research/deep/:taskId" element={<DeepResearchPage />} />
|
||||
|
||||
{/* 标题摘要初筛 */}
|
||||
<Route path="screening/title">
|
||||
|
||||
@@ -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<Phase>(0);
|
||||
const [query, setQuery] = useState('');
|
||||
const [taskId, setTaskId] = useState<string | null>(null);
|
||||
const [generatedRequirement, setGeneratedRequirement] = useState('');
|
||||
const [intentSummary, setIntentSummary] = useState<IntentSummary | null>(null);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [restoredFromUrl, setRestoredFromUrl] = useState(false);
|
||||
|
||||
const strategyRef = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const resultsRef = useRef<HTMLDivElement>(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<HTMLDivElement | null>) => {
|
||||
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 (
|
||||
<div className="h-full overflow-auto bg-gray-50 pb-16">
|
||||
<div className="max-w-5xl mx-auto px-6 pt-6">
|
||||
{/* 顶部横幅:完成/失败状态 + 导出按钮 */}
|
||||
{isFinished && (
|
||||
<HistoryBanner task={task} onNewSearch={handleNewSearch} />
|
||||
)}
|
||||
{!isFinished && (
|
||||
<Card size="small" className="mb-4 !bg-blue-50 !border-blue-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<ClockCircleOutlined className="text-lg text-blue-500" />
|
||||
<Typography.Text strong>「{task.query}」 — 深度检索执行中...</Typography.Text>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 检索需求书(PICOS + 完整指令) */}
|
||||
{task.confirmedRequirement && task.aiIntentSummary && (
|
||||
<div className="mt-4">
|
||||
<StrategyConfirm
|
||||
generatedRequirement={task.confirmedRequirement}
|
||||
intentSummary={task.aiIntentSummary}
|
||||
onConfirm={() => {}}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 执行日志 */}
|
||||
<div className="mt-4">
|
||||
<AgentTerminal task={task} isRunning={!isFinished && isRunning} isFailed={isFailed} />
|
||||
</div>
|
||||
|
||||
{/* 结果区(报告 + 文献表格),横幅已在顶部展示 */}
|
||||
{isFinished && (
|
||||
<div className="mt-4">
|
||||
<ResultsView task={task} onNewSearch={handleNewSearch} hideBanner />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 恢复中但 task 还未加载 — loading
|
||||
if (restoredFromUrl && !task) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-gray-50">
|
||||
<div className="text-slate-400 text-sm">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 正常新建流程 ──
|
||||
if (phase === 0) {
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-gray-50">
|
||||
@@ -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 (
|
||||
<Card size="small" className="mb-4 !bg-red-50 !border-red-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CloseCircleFilled className="text-lg text-red-500" />
|
||||
<div>
|
||||
<Text strong>检索失败</Text>
|
||||
<Text type="secondary" className="text-sm ml-2">「{task.query}」</Text>
|
||||
</div>
|
||||
</div>
|
||||
<Button icon={<PlusOutlined />} onClick={onNewSearch}>新建检索</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small" className="mb-4 !bg-green-50 !border-green-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircleFilled className="text-xl text-green-500" />
|
||||
<div>
|
||||
<Text strong>Deep Research 完成</Text>
|
||||
<Text type="secondary" className="text-sm ml-2">
|
||||
「{task.query}」 — {task.resultCount || 0} 篇文献
|
||||
{task.completedAt && ` · ${new Date(task.completedAt).toLocaleString()}`}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button icon={<DownloadOutlined />} loading={exporting} onClick={handleExportWord}>
|
||||
导出 Word
|
||||
</Button>
|
||||
<Button icon={<PlusOutlined />} onClick={onNewSearch}>新建检索</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeepResearchPage;
|
||||
|
||||
373
frontend-v2/src/modules/asl/styles/asl-sidebar.css
Normal file
373
frontend-v2/src/modules/asl/styles/asl-sidebar.css
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -99,6 +99,29 @@ export default function AgentModal({ visible, taskCount, onClose, onConfirm, isS
|
||||
</div>
|
||||
<span className="tag tag-purple">深度</span>
|
||||
</label>
|
||||
|
||||
{/* 临床专业评估智能体 */}
|
||||
<label
|
||||
className={`flex items-start gap-3 p-4 border rounded-xl cursor-pointer transition-all ${
|
||||
selectedAgents.includes('clinical')
|
||||
? 'border-indigo-500 bg-indigo-50'
|
||||
: 'border-gray-200 hover:border-indigo-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedAgents.includes('clinical')}
|
||||
onChange={() => toggleAgent('clinical')}
|
||||
className="mt-1 w-4 h-4 text-indigo-600 rounded border-gray-300 focus:ring-indigo-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="block font-bold text-slate-800 text-sm">临床专业评估智能体</span>
|
||||
<span className="block text-xs text-slate-500 mt-0.5">
|
||||
基于 FINER 标准评估创新性、临床价值、科学性、可行性
|
||||
</span>
|
||||
</div>
|
||||
<span className="tag tag-pink">专业</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
|
||||
181
frontend-v2/src/modules/rvw/components/ClinicalReport.tsx
Normal file
181
frontend-v2/src/modules/rvw/components/ClinicalReport.tsx
Normal file
@@ -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<Set<number>>(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 (
|
||||
<div className="space-y-6 fade-in">
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div className="p-6 bg-gradient-to-r from-pink-50 to-white">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Stethoscope className="w-5 h-5 text-pink-600" />
|
||||
<h3 className="font-bold text-lg text-slate-800">临床专业评估</h3>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">基于 FINER 标准的研究选题系统评估</p>
|
||||
</div>
|
||||
<div className="p-6 prose prose-sm prose-slate max-w-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{data.report}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 fade-in">
|
||||
{/* 总览卡片 */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div className="p-6 bg-gradient-to-r from-pink-50 to-white">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Stethoscope className="w-5 h-5 text-pink-600" />
|
||||
<h3 className="font-bold text-lg text-slate-800">临床专业评估</h3>
|
||||
</div>
|
||||
{summary ? (
|
||||
<div className="text-slate-600 text-sm leading-relaxed mb-4 prose prose-sm prose-slate max-w-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{summary}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500 mb-4">基于 FINER 标准的研究选题系统评估</p>
|
||||
)}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<span className="text-sm text-slate-600">共 <span className="font-bold text-slate-800">{sections.length}</span> 个评估维度</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分项标题 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<TrendingUp className="w-5 h-5 text-pink-500" />
|
||||
<h3 className="font-bold text-base text-slate-800">分项评估</h3>
|
||||
<span className="text-xs text-slate-400 bg-slate-100 px-2 py-0.5 rounded">共 {sections.length} 项</span>
|
||||
</div>
|
||||
|
||||
{/* 各评估维度卡片 */}
|
||||
<div className="space-y-4">
|
||||
{sections.map((section, idx) => {
|
||||
const style = getSectionIcon(section.title);
|
||||
const isCollapsed = collapsedSections.has(idx);
|
||||
|
||||
return (
|
||||
<div key={idx} className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div
|
||||
className={`px-5 py-4 border-b ${style.bg} ${style.border} cursor-pointer hover:brightness-95 transition-all`}
|
||||
onClick={() => toggleSection(idx)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Lightbulb className={`w-5 h-5 ${style.color}`} />
|
||||
<h4 className="font-semibold text-slate-800">{section.title}</h4>
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<ChevronDown className="w-5 h-5 text-slate-400" />
|
||||
) : (
|
||||
<ChevronUp className="w-5 h-5 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && section.content && (
|
||||
<div className="px-5 py-4 prose prose-sm prose-slate max-w-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{section.content}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6 fade-in">
|
||||
{/* 评分总览卡片 */}
|
||||
{/* 总览卡片 */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div className="p-6 bg-gradient-to-r from-slate-50 to-white">
|
||||
<div className="flex items-start gap-8">
|
||||
{/* 分数环 */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`w-24 h-24 rounded-full border-4 ${grade.bg.replace('bg-', 'border-')} flex items-center justify-center bg-white shadow-lg`}>
|
||||
<div className="text-center">
|
||||
<span className={`text-3xl font-bold ${grade.color}`}>{Number(data.overall_score).toFixed(1)}</span>
|
||||
<span className="text-xs text-slate-400 block">分</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`mt-2 px-3 py-1 rounded-full text-xs font-bold ${grade.bg} text-white`}>
|
||||
{grade.label}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText className="w-5 h-5 text-indigo-500" />
|
||||
<h3 className="font-bold text-lg text-slate-800">稿约规范性评估</h3>
|
||||
</div>
|
||||
<p className="text-slate-600 text-sm leading-relaxed mb-4">{data.summary}</p>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-50 rounded-lg border border-green-100">
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm font-medium text-green-700">{stats.pass} 项通过</span>
|
||||
</div>
|
||||
|
||||
{/* 评估摘要 */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText className="w-5 h-5 text-indigo-500" />
|
||||
<h3 className="font-bold text-lg text-slate-800">稿约规范性评估</h3>
|
||||
{stats.warning > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-amber-50 rounded-lg border border-amber-100">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
||||
<span className="text-sm font-medium text-amber-700">{stats.warning} 项警告</span>
|
||||
</div>
|
||||
<p className="text-slate-600 text-sm leading-relaxed mb-4">{data.summary}</p>
|
||||
|
||||
{/* 统计指标 */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-50 rounded-lg border border-green-100">
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm font-medium text-green-700">{stats.pass} 项通过</span>
|
||||
</div>
|
||||
{stats.warning > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-amber-50 rounded-lg border border-amber-100">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
||||
<span className="text-sm font-medium text-amber-700">{stats.warning} 项警告</span>
|
||||
</div>
|
||||
)}
|
||||
{stats.fail > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-50 rounded-lg border border-red-100">
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
<span className="text-sm font-medium text-red-700">{stats.fail} 项不通过</span>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{stats.fail > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-50 rounded-lg border border-red-100">
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
<span className="text-sm font-medium text-red-700">{stats.fail} 项不通过</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,14 +100,9 @@ export default function EditorialReport({ data }: EditorialReportProps) {
|
||||
{getStatusIcon(item.status)}
|
||||
<h4 className="font-semibold text-slate-800">{item.criterion}</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2.5 py-1 rounded-md text-xs font-bold ${colors.badge}`}>
|
||||
{item.score}分
|
||||
</span>
|
||||
<span className={`px-2.5 py-1 rounded-md text-xs font-medium ${colors.badge}`}>
|
||||
{getStatusLabel(item.status)}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`px-2.5 py-1 rounded-md text-xs font-medium ${colors.badge}`}>
|
||||
{getStatusLabel(item.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
};
|
||||
|
||||
export default function ForensicsReport({ data }: ForensicsReportProps) {
|
||||
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set());
|
||||
const hasLlm = !!(data?.llmReport || Object.keys(data?.llmTableReports || {}).length > 0);
|
||||
const [expandedTables, setExpandedTables] = useState<Set<string>>(() => {
|
||||
if (hasLlm) {
|
||||
return new Set((data?.tables || []).map(t => t.id));
|
||||
}
|
||||
return new Set();
|
||||
});
|
||||
const [highlightedCell, setHighlightedCell] = useState<string | null>(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<string, string> = {};
|
||||
@@ -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]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -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
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 表格问题 */}
|
||||
{/* LLM 核查结果(该表格) */}
|
||||
{llmTableReport && (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="bg-indigo-50/60 rounded-lg p-4 border border-indigo-100">
|
||||
<p className="text-xs font-semibold text-indigo-600 uppercase tracking-wider mb-2 flex items-center gap-1">
|
||||
<FlaskConical className="w-3.5 h-3.5" />
|
||||
AI 核查结果
|
||||
</p>
|
||||
<div className="prose prose-sm prose-slate max-w-none text-sm">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{llmTableReport}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 规则验证详情 */}
|
||||
{issues.length > 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
该表格发现的问题
|
||||
规则验证详情
|
||||
</p>
|
||||
{issues.map((issue, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 text-sm">
|
||||
|
||||
@@ -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: <AlertTriangle className="w-4 h-4 text-amber-500" />, 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 (
|
||||
<div className="space-y-6 fade-in">
|
||||
{/* 评分总览卡片 */}
|
||||
{/* 总览卡片 */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div className="p-6 bg-gradient-to-r from-slate-50 to-white">
|
||||
<div className="flex items-start gap-8">
|
||||
{/* 分数环 */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`w-24 h-24 rounded-full border-4 ${grade.bg.replace('bg-', 'border-')} flex items-center justify-center bg-white shadow-lg`}>
|
||||
<div className="text-center">
|
||||
<span className={`text-3xl font-bold ${grade.color}`}>{Number(data.overall_score).toFixed(1)}</span>
|
||||
<span className="text-xs text-slate-400 block">分</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`mt-2 px-3 py-1 rounded-full text-xs font-bold ${grade.bg} text-white`}>
|
||||
{grade.label}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Microscope className="w-5 h-5 text-purple-500" />
|
||||
<h3 className="font-bold text-lg text-slate-800">方法学评估</h3>
|
||||
</div>
|
||||
<p className="text-slate-600 text-sm leading-relaxed mb-4">{data.summary}</p>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<span className="text-sm text-slate-600">共检测 <span className="font-bold text-slate-800">{data.parts.length}</span> 个方面</span>
|
||||
</div>
|
||||
|
||||
{/* 评估摘要 */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Microscope className="w-5 h-5 text-purple-500" />
|
||||
<h3 className="font-bold text-lg text-slate-800">方法学评估</h3>
|
||||
<span className={`px-2.5 py-1 rounded-md text-xs font-bold ${status.bg} ${status.color} ${status.border} border`}>
|
||||
{status.label}
|
||||
</span>
|
||||
{totalIssues === 0 ? (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-50 rounded-lg border border-green-100">
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm font-medium text-green-700">未发现问题</span>
|
||||
</div>
|
||||
<p className="text-slate-600 text-sm leading-relaxed mb-4">{data.summary}</p>
|
||||
|
||||
{/* 统计指标 */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<span className="text-sm text-slate-600">共检测 <span className="font-bold text-slate-800">{data.parts.length}</span> 个方面</span>
|
||||
</div>
|
||||
{totalIssues === 0 ? (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-50 rounded-lg border border-green-100">
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm font-medium text-green-700">未发现问题</span>
|
||||
) : (
|
||||
<>
|
||||
{majorIssues > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-50 rounded-lg border border-red-100">
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
<span className="text-sm font-medium text-red-700">{majorIssues} 个严重问题</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{majorIssues > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-50 rounded-lg border border-red-100">
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
<span className="text-sm font-medium text-red-700">{majorIssues} 个严重问题</span>
|
||||
</div>
|
||||
)}
|
||||
{minorIssues > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-amber-50 rounded-lg border border-amber-100">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
||||
<span className="text-sm font-medium text-amber-700">{minorIssues} 个轻微问题</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{minorIssues > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-amber-50 rounded-lg border border-amber-100">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
||||
<span className="text-sm font-medium text-amber-700">{minorIssues} 个轻微问题</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,24 +81,16 @@ export default function MethodologyReport({ data }: MethodologyReportProps) {
|
||||
)}
|
||||
<h4 className="font-semibold text-slate-800">{part.part}</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2.5 py-1 rounded-md text-xs font-bold ${
|
||||
part.score >= 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 ? (
|
||||
<span className="px-2.5 py-1 rounded-md text-xs font-medium bg-green-100 text-green-700 flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
无问题
|
||||
</span>
|
||||
{part.issues.length === 0 ? (
|
||||
<span className="px-2.5 py-1 rounded-md text-xs font-medium bg-green-100 text-green-700 flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
无问题
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2.5 py-1 rounded-md text-xs font-medium bg-amber-100 text-amber-700">
|
||||
{part.issues.length} 个问题
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="px-2.5 py-1 rounded-md text-xs font-medium bg-amber-100 text-amber-700">
|
||||
{part.issues.length} 个问题
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<TaskStatus, { label: string; color: string; icon: any
|
||||
reviewing_editorial: { label: '正在审查稿约规范性', color: 'text-indigo-500', icon: Bot },
|
||||
reviewing_methodology: { label: '正在审查方法学', color: 'text-purple-500', icon: Bot },
|
||||
completed: { label: '审查完成', color: 'text-green-600', icon: CheckCircle },
|
||||
partial_completed: { label: '部分完成', color: 'text-amber-500', icon: AlertTriangle },
|
||||
failed: { label: '审查失败', color: 'text-red-500', icon: AlertCircle },
|
||||
};
|
||||
|
||||
@@ -45,6 +47,9 @@ const getProgressSteps = (selectedAgents: string[]) => {
|
||||
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
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 部分完成警告 */}
|
||||
{isPartial && task.errorDetails && (
|
||||
<div className="bg-amber-50 rounded-xl border border-amber-200 p-5 mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-amber-800 mb-1">部分模块未能完成</h3>
|
||||
<p className="text-amber-700 text-sm mb-3">
|
||||
{task.errorDetails.successCount} 个模块成功,{task.errorDetails.errorCount + task.errorDetails.timeoutCount} 个模块失败。已展示成功模块的结果。
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{task.errorDetails.failedSkills.map((skill, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
skill.status === 'timeout' ? 'bg-amber-100 text-amber-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{skill.status === 'timeout' ? '超时' : '失败'}
|
||||
</span>
|
||||
<span className="text-amber-800 font-medium">{skill.skillName}</span>
|
||||
<span className="text-amber-600">— {skill.error}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 完成状态 - 显示报告 */}
|
||||
{isCompleted && report && (
|
||||
<>
|
||||
{/* 分数卡片 */}
|
||||
<div className="bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl shadow-lg p-6 mb-8 text-white">
|
||||
{/* 审查完成信息 */}
|
||||
<div className="bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl shadow-lg p-5 mb-8 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-1">综合评分</h2>
|
||||
<p className="text-indigo-100 text-sm">
|
||||
审查用时 {report.durationSeconds ? formatTime(report.durationSeconds) : '-'}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="w-6 h-6 text-white/90" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">审查完成</h2>
|
||||
<p className="text-indigo-100 text-sm">
|
||||
审查用时 {report.durationSeconds ? formatTime(report.durationSeconds) : '-'}
|
||||
{report.completedAt && ` · ${new Date(report.completedAt).toLocaleString('zh-CN')}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-5xl font-bold">{report.overallScore != null ? Number(report.overallScore).toFixed(1) : '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}分)
|
||||
稿约规范性
|
||||
</button>
|
||||
)}
|
||||
{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}分)
|
||||
方法学评估
|
||||
</button>
|
||||
)}
|
||||
{report.forensicsResult && (
|
||||
@@ -579,6 +651,18 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
数据验证 ({report.forensicsResult.summary.totalIssues || 0}个问题)
|
||||
</button>
|
||||
)}
|
||||
{report.clinicalReview && (
|
||||
<button
|
||||
onClick={() => setActiveTab('clinical')}
|
||||
className={`px-6 py-2 rounded-md text-sm transition-all ${
|
||||
activeTab === 'clinical'
|
||||
? 'font-bold bg-white text-indigo-600 shadow-sm'
|
||||
: 'font-medium text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
临床评估
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 非 docx 文件无数据验证提示 */}
|
||||
@@ -611,6 +695,9 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
|
||||
{activeTab === 'forensics' && report.forensicsResult && (
|
||||
<ForensicsReport data={report.forensicsResult} />
|
||||
)}
|
||||
{activeTab === 'clinical' && report.clinicalReview && (
|
||||
<ClinicalReport data={report.clinicalReview} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -111,8 +111,8 @@ export default function TaskTable({
|
||||
);
|
||||
}
|
||||
|
||||
// 已完成:[查看报告] [重新审稿] [删除]
|
||||
if (task.status === 'completed') {
|
||||
// 已完成 / 部分完成:[查看报告] [重新审稿] [删除]
|
||||
if (task.status === 'completed' || task.status === 'partial_completed') {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
@@ -214,8 +214,8 @@ export default function TaskTable({
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div
|
||||
className={`font-bold text-slate-800 text-sm mb-0.5 truncate ${task.status === 'completed' ? 'cursor-pointer hover:text-indigo-600' : ''}`}
|
||||
onClick={() => task.status === 'completed' && onViewReport(task)}
|
||||
className={`font-bold text-slate-800 text-sm mb-0.5 truncate ${(task.status === 'completed' || task.status === 'partial_completed') ? 'cursor-pointer hover:text-indigo-600' : ''}`}
|
||||
onClick={() => (task.status === 'completed' || task.status === 'partial_completed') && onViewReport(task)}
|
||||
title={task.fileName}
|
||||
>
|
||||
{task.fileName}
|
||||
@@ -229,7 +229,7 @@ export default function TaskTable({
|
||||
</>
|
||||
)}
|
||||
{/* 将结果摘要整合到这里 */}
|
||||
{task.status === 'completed' && task.editorialScore !== undefined && (
|
||||
{(task.status === 'completed' || task.status === 'partial_completed') && task.editorialScore !== undefined && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className={`text-[10px] font-medium ${task.editorialScore >= 80 ? 'text-green-600' : task.editorialScore >= 60 ? 'text-amber-600' : 'text-red-600'}`}>
|
||||
@@ -237,7 +237,13 @@ export default function TaskTable({
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{task.status === 'completed' && task.methodologyScore !== undefined && (
|
||||
{task.status === 'partial_completed' && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-[10px] font-medium text-amber-600">部分完成</span>
|
||||
</>
|
||||
)}
|
||||
{(task.status === 'completed' || task.status === 'partial_completed') && task.methodologyScore !== undefined && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className={`text-[10px] font-medium ${task.methodologyScore >= 80 ? 'text-green-600' : task.methodologyScore >= 60 ? 'text-amber-600' : 'text-red-600'}`}>
|
||||
|
||||
@@ -10,10 +10,11 @@ export type TaskStatus =
|
||||
| 'reviewing_editorial' // 正在审查稿约规范性
|
||||
| 'reviewing_methodology' // 正在审查方法学
|
||||
| 'completed' // 已完成
|
||||
| 'partial_completed' // 部分完成(部分模块成功,部分失败/超时)
|
||||
| 'failed'; // 失败
|
||||
|
||||
// 智能体类型
|
||||
export type AgentType = 'editorial' | 'methodology';
|
||||
export type AgentType = 'editorial' | 'methodology' | 'clinical';
|
||||
|
||||
// 审查任务
|
||||
export interface ReviewTask {
|
||||
@@ -28,6 +29,19 @@ export interface ReviewTask {
|
||||
methodologyScore?: number; // 方法学分数
|
||||
methodologyStatus?: string; // 方法学状态(通过/存疑/不通过)
|
||||
errorMessage?: string;
|
||||
errorDetails?: {
|
||||
failedSkills: Array<{
|
||||
skillId: string;
|
||||
skillName: string;
|
||||
status: 'error' | 'timeout';
|
||||
error: string;
|
||||
executionTime: number;
|
||||
}>;
|
||||
successCount: number;
|
||||
errorCount: number;
|
||||
timeoutCount: number;
|
||||
totalSkills: number;
|
||||
};
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
durationSeconds?: number;
|
||||
@@ -110,6 +124,14 @@ export interface ForensicsResult {
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
};
|
||||
llmReport?: string;
|
||||
llmTableReports?: Record<string, string>;
|
||||
}
|
||||
|
||||
// 临床专业评估结果
|
||||
export interface ClinicalReviewResult {
|
||||
report: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
// 完整审查报告
|
||||
@@ -117,6 +139,7 @@ export interface ReviewReport extends ReviewTask {
|
||||
editorialReview?: EditorialReviewResult;
|
||||
methodologyReview?: MethodologyReviewResult;
|
||||
forensicsResult?: ForensicsResult;
|
||||
clinicalReview?: ClinicalReviewResult;
|
||||
modelUsed?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user