From 205932bb3f304edce0049a4946abdb2d96a3b904 Mon Sep 17 00:00:00 2001 From: HaHafeng Date: Thu, 26 Feb 2026 21:51:02 +0800 Subject: [PATCH] feat(asl): Complete Tool 4 SR Chart Generator and Tool 5 Meta Analysis Engine Tool 4 - SR Chart Generator: - PRISMA 2020 flow diagram with Chinese/English toggle (SVG) - Baseline characteristics table (Table 1) - Dual data source: project pipeline API + Excel upload - SVG/PNG export support - Backend: ChartingService with Prisma aggregation - Frontend: PrismaFlowDiagram, BaselineTable, DataSourceSelector Tool 5 - Meta Analysis Engine: - 3 data types: HR (metagen), dichotomous (metabin), continuous (metacont) - Random and fixed effects models - Multiple effect measures: HR / OR / RR - Forest plot + funnel plot (base64 PNG from R) - Heterogeneity statistics: I2, Q, p-value, Tau2 - Data input via Excel upload or project pipeline - R Docker image updated with meta package (13 tools total) - E2E test: 36/36 passed - Key fix: exp() back-transformation for log-scale ratio measures Also includes: - IIT CRA Agent V3.0 routing and AI chat page integration - Updated ASL module status guide (v2.3) - Updated system status guide (v6.3) - Updated R statistics engine guide (v1.4) Tested: Frontend renders correctly, backend APIs functional, E2E tests passed Made-with: Cursor --- .../controllers/ChartingController.ts | 41 ++ .../src/modules/asl/charting/routes/index.ts | 11 + .../asl/charting/services/ChartingService.ts | 156 +++++++ .../meta-analysis/__tests__/meta-e2e-test.ts | 299 ++++++++++++ .../controllers/MetaAnalysisController.ts | 42 ++ .../modules/asl/meta-analysis/routes/index.ts | 16 + .../services/MetaAnalysisService.ts | 192 ++++++++ backend/src/modules/asl/routes/index.ts | 8 + .../src/modules/iit-manager/routes/index.ts | 57 ++- .../00-系统当前状态与开发指南.md | 68 ++- .../06-R统计引擎/01-R统计引擎架构与部署指南.md | 175 ++++++- .../ASL-AI智能文献/00-模块当前状态与开发指南.md | 161 ++++++- frontend-v2/src/App.tsx | 10 +- .../src/framework/layout/AdminLayout.tsx | 9 +- frontend-v2/src/modules/asl/api/index.ts | 49 ++ .../src/modules/asl/components/ASLLayout.tsx | 14 +- .../asl/components/charting/BaselineTable.tsx | 110 +++++ .../charting/DataSourceSelector.tsx | 224 +++++++++ .../components/charting/PrismaFlowDiagram.tsx | 228 +++++++++ .../asl/components/meta/ResultsPanel.tsx | 232 ++++++++++ frontend-v2/src/modules/asl/index.tsx | 12 + .../modules/asl/pages/MetaAnalysisEngine.tsx | 438 ++++++++++++++++++ .../modules/asl/pages/SRChartGenerator.tsx | 146 ++++++ .../modules/asl/utils/chartingExcelUtils.ts | 220 +++++++++ .../src/modules/asl/utils/metaExcelUtils.ts | 240 ++++++++++ frontend-v2/src/modules/iit/IitLayout.tsx | 45 +- frontend-v2/src/modules/iit/index.tsx | 14 +- .../src/modules/iit/pages/AiChatPage.tsx | 195 ++++++++ r-statistics-service/Dockerfile | 3 +- r-statistics-service/tools/meta_analysis.R | 295 ++++++++++++ 30 files changed, 3596 insertions(+), 114 deletions(-) create mode 100644 backend/src/modules/asl/charting/controllers/ChartingController.ts create mode 100644 backend/src/modules/asl/charting/routes/index.ts create mode 100644 backend/src/modules/asl/charting/services/ChartingService.ts create mode 100644 backend/src/modules/asl/meta-analysis/__tests__/meta-e2e-test.ts create mode 100644 backend/src/modules/asl/meta-analysis/controllers/MetaAnalysisController.ts create mode 100644 backend/src/modules/asl/meta-analysis/routes/index.ts create mode 100644 backend/src/modules/asl/meta-analysis/services/MetaAnalysisService.ts create mode 100644 frontend-v2/src/modules/asl/components/charting/BaselineTable.tsx create mode 100644 frontend-v2/src/modules/asl/components/charting/DataSourceSelector.tsx create mode 100644 frontend-v2/src/modules/asl/components/charting/PrismaFlowDiagram.tsx create mode 100644 frontend-v2/src/modules/asl/components/meta/ResultsPanel.tsx create mode 100644 frontend-v2/src/modules/asl/pages/MetaAnalysisEngine.tsx create mode 100644 frontend-v2/src/modules/asl/pages/SRChartGenerator.tsx create mode 100644 frontend-v2/src/modules/asl/utils/chartingExcelUtils.ts create mode 100644 frontend-v2/src/modules/asl/utils/metaExcelUtils.ts create mode 100644 frontend-v2/src/modules/iit/pages/AiChatPage.tsx create mode 100644 r-statistics-service/tools/meta_analysis.R diff --git a/backend/src/modules/asl/charting/controllers/ChartingController.ts b/backend/src/modules/asl/charting/controllers/ChartingController.ts new file mode 100644 index 00000000..32fe2907 --- /dev/null +++ b/backend/src/modules/asl/charting/controllers/ChartingController.ts @@ -0,0 +1,41 @@ +import { FastifyRequest, FastifyReply } from 'fastify'; +import { chartingService } from '../services/ChartingService.js'; +import { logger } from '../../../../common/logging/index.js'; + +function getUserId(request: FastifyRequest): string { + const userId = (request as any).user?.userId; + if (!userId) throw new Error('User not authenticated'); + return userId; +} + +export async function getPrismaData( + request: FastifyRequest<{ Params: { projectId: string } }>, + reply: FastifyReply, +) { + try { + const { projectId } = request.params; + getUserId(request); + + const data = await chartingService.getPrismaData(projectId); + return reply.send({ success: true, data }); + } catch (err: any) { + logger.error('[Charting] getPrismaData error', { error: err.message }); + return reply.status(500).send({ success: false, error: err.message }); + } +} + +export async function getBaselineData( + request: FastifyRequest<{ Params: { projectId: string } }>, + reply: FastifyReply, +) { + try { + const { projectId } = request.params; + getUserId(request); + + const data = await chartingService.getBaselineData(projectId); + return reply.send({ success: true, data }); + } catch (err: any) { + logger.error('[Charting] getBaselineData error', { error: err.message }); + return reply.status(500).send({ success: false, error: err.message }); + } +} diff --git a/backend/src/modules/asl/charting/routes/index.ts b/backend/src/modules/asl/charting/routes/index.ts new file mode 100644 index 00000000..880c7428 --- /dev/null +++ b/backend/src/modules/asl/charting/routes/index.ts @@ -0,0 +1,11 @@ +import { FastifyInstance } from 'fastify'; +import * as chartingController from '../controllers/ChartingController.js'; +import { authenticate, requireModule } from '../../../../common/auth/auth.middleware.js'; + +export async function chartingRoutes(fastify: FastifyInstance) { + fastify.addHook('onRequest', authenticate); + fastify.addHook('onRequest', requireModule('ASL')); + + fastify.get('/prisma-data/:projectId', chartingController.getPrismaData); + fastify.get('/baseline-data/:projectId', chartingController.getBaselineData); +} diff --git a/backend/src/modules/asl/charting/services/ChartingService.ts b/backend/src/modules/asl/charting/services/ChartingService.ts new file mode 100644 index 00000000..106c35cf --- /dev/null +++ b/backend/src/modules/asl/charting/services/ChartingService.ts @@ -0,0 +1,156 @@ +import { prisma } from '../../../../config/database.js'; +import { logger } from '../../../../common/logging/index.js'; + +export interface PrismaAggregation { + totalIdentified: number; + duplicatesRemoved: number; + recordsScreened: number; + titleAbstractExcluded: number; + titleAbstractExclusionReasons: Record; + fullTextAssessed: number; + fullTextExcluded: number; + fullTextExclusionReasons: Record; + finalIncluded: number; +} + +export interface BaselineRow { + studyId: string; + snapshotFilename: string; + extractedData: Record; +} + +function flattenModuleData(moduleData: any): Record { + if (!moduleData) return {}; + if (Array.isArray(moduleData)) { + const flat: Record = {}; + for (const item of moduleData) { + if (item && typeof item === 'object' && 'key' in item) { + flat[item.key] = item.value ?? ''; + } + } + return flat; + } + const flat: Record = {}; + for (const [k, v] of Object.entries(moduleData)) { + if (v && typeof v === 'object' && 'value' in (v as any)) { + flat[k] = (v as any).value ?? ''; + } else { + flat[k] = v; + } + } + return flat; +} + +export class ChartingService { + + async getPrismaData(projectId: string): Promise { + logger.info(`[Charting] Aggregating PRISMA data for project ${projectId}`); + + const [ + totalLiteratures, + screeningExcluded, + screeningIncluded, + fulltextExcluded, + fulltextIncluded, + approvedResults, + ] = await Promise.all([ + prisma.aslLiterature.count({ where: { projectId } }), + + prisma.aslScreeningResult.count({ + where: { projectId, finalDecision: 'exclude' }, + }), + prisma.aslScreeningResult.count({ + where: { projectId, finalDecision: 'include' }, + }), + + prisma.aslFulltextScreeningResult.count({ + where: { projectId, finalDecision: 'exclude' }, + }), + prisma.aslFulltextScreeningResult.count({ + where: { projectId, finalDecision: 'include' }, + }), + + prisma.aslExtractionResult.count({ + where: { projectId, reviewStatus: 'approved' }, + }), + ]); + + const fulltextExclusionReasons = await this.aggregateFulltextExclusionReasons(projectId); + const titleExclusionReasons = await this.aggregateTitleExclusionReasons(projectId); + + const duplicatesRemoved = 0; + const recordsScreened = totalLiteratures - duplicatesRemoved; + const fullTextAssessed = recordsScreened - screeningExcluded; + + return { + totalIdentified: totalLiteratures, + duplicatesRemoved, + recordsScreened, + titleAbstractExcluded: screeningExcluded, + titleAbstractExclusionReasons: titleExclusionReasons, + fullTextAssessed, + fullTextExcluded: fulltextExcluded, + fullTextExclusionReasons: fulltextExclusionReasons, + finalIncluded: approvedResults, + }; + } + + async getBaselineData(projectId: string): Promise { + logger.info(`[Charting] Fetching baseline data for project ${projectId}`); + + const results = await prisma.aslExtractionResult.findMany({ + where: { projectId, reviewStatus: 'approved', status: 'completed' }, + select: { + snapshotFilename: true, + extractedData: true, + }, + orderBy: { createdAt: 'asc' }, + }); + + return results.map((r) => { + const data = (r.extractedData ?? {}) as Record; + const metaFlat = flattenModuleData(data.metadata); + const baselineFlat = flattenModuleData(data.baseline); + + return { + studyId: metaFlat.study_id || r.snapshotFilename || 'Unknown', + snapshotFilename: r.snapshotFilename, + extractedData: { ...metaFlat, ...baselineFlat }, + }; + }); + } + + private async aggregateTitleExclusionReasons( + projectId: string, + ): Promise> { + const excluded = await prisma.aslScreeningResult.findMany({ + where: { projectId, finalDecision: 'exclude' }, + select: { exclusionReason: true }, + }); + + const counts: Record = {}; + for (const r of excluded) { + const reason = r.exclusionReason?.trim() || 'Unspecified'; + counts[reason] = (counts[reason] || 0) + 1; + } + return counts; + } + + private async aggregateFulltextExclusionReasons( + projectId: string, + ): Promise> { + const excluded = await prisma.aslFulltextScreeningResult.findMany({ + where: { projectId, finalDecision: 'exclude' }, + select: { exclusionReason: true }, + }); + + const counts: Record = {}; + for (const r of excluded) { + const reason = r.exclusionReason?.trim() || 'Unspecified'; + counts[reason] = (counts[reason] || 0) + 1; + } + return counts; + } +} + +export const chartingService = new ChartingService(); diff --git a/backend/src/modules/asl/meta-analysis/__tests__/meta-e2e-test.ts b/backend/src/modules/asl/meta-analysis/__tests__/meta-e2e-test.ts new file mode 100644 index 00000000..5959ec7f --- /dev/null +++ b/backend/src/modules/asl/meta-analysis/__tests__/meta-e2e-test.ts @@ -0,0 +1,299 @@ +/** + * Meta Analysis Engine — End-to-End Test + * + * Tests the full pipeline: Node.js Backend → R Docker → Results + * Covers all 3 data types: HR, Dichotomous, Continuous + * + * Usage: + * npx tsx src/modules/asl/meta-analysis/__tests__/meta-e2e-test.ts + */ + +const R_SERVICE_URL = process.env.R_SERVICE_URL || 'http://localhost:8082'; +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; +const R_TOOL_CODE = 'ST_META_ANALYSIS'; + +// ==================== Test Data ==================== + +const HR_DATA = [ + { study_id: 'Gandhi 2018', hr: 0.49, lower_ci: 0.38, upper_ci: 0.64 }, + { study_id: 'Socinski 2018', hr: 0.56, lower_ci: 0.45, upper_ci: 0.70 }, + { study_id: 'West 2019', hr: 0.60, lower_ci: 0.45, upper_ci: 0.80 }, + { study_id: 'Paz-Ares 2018', hr: 0.64, lower_ci: 0.49, upper_ci: 0.85 }, + { study_id: 'Jotte 2020', hr: 0.71, lower_ci: 0.56, upper_ci: 0.91 }, +]; + +const DICHOTOMOUS_DATA = [ + { study_id: 'Smith 2020', events_e: 25, total_e: 100, events_c: 40, total_c: 100 }, + { study_id: 'Jones 2021', events_e: 30, total_e: 150, events_c: 55, total_c: 150 }, + { study_id: 'Wang 2022', events_e: 12, total_e: 80, events_c: 22, total_c: 80 }, + { study_id: 'Lee 2021', events_e: 18, total_e: 120, events_c: 35, total_c: 120 }, +]; + +const CONTINUOUS_DATA = [ + { study_id: 'Trial A', mean_e: 5.2, sd_e: 1.3, n_e: 50, mean_c: 6.8, sd_c: 1.5, n_c: 50 }, + { study_id: 'Trial B', mean_e: 4.8, sd_e: 1.1, n_e: 60, mean_c: 6.5, sd_c: 1.4, n_c: 60 }, + { study_id: 'Trial C', mean_e: 5.0, sd_e: 1.2, n_e: 45, mean_c: 7.0, sd_c: 1.6, n_c: 45 }, + { study_id: 'Trial D', mean_e: 5.5, sd_e: 1.4, n_e: 55, mean_c: 6.2, sd_c: 1.3, n_c: 55 }, + { study_id: 'Trial E', mean_e: 4.5, sd_e: 1.0, n_e: 40, mean_c: 6.0, sd_c: 1.2, n_c: 40 }, +]; + +// ==================== Helpers ==================== + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, msg: string) { + if (condition) { + console.log(` ✅ ${msg}`); + passed++; + } else { + console.log(` ❌ FAIL: ${msg}`); + failed++; + } +} + +async function postJson(url: string, body: unknown): Promise { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return res.json(); +} + +// ==================== Tests ==================== + +async function testRHealth() { + console.log('\n🔍 Test 0: R Service Health Check'); + try { + const res = await fetch(`${R_SERVICE_URL}/health`); + const data = await res.json(); + const status = Array.isArray(data.status) ? data.status[0] : data.status; + assert(status === 'ok', `R service status: ${status}`); + const toolCount = Array.isArray(data.tools_loaded) ? data.tools_loaded[0] : data.tools_loaded; + assert(typeof toolCount === 'number' && toolCount > 0, `Tools loaded: ${toolCount}`); + console.log(` R service version: ${data.version}, dev_mode: ${data.dev_mode}`); + } catch (e: any) { + assert(false, `R service unreachable: ${e.message}`); + } +} + +async function testRToolList() { + console.log('\n🔍 Test 0.5: Verify meta_analysis tool registered'); + try { + const res = await fetch(`${R_SERVICE_URL}/api/v1/tools`); + const data = await res.json(); + const tools: string[] = data.tools || []; + assert(tools.includes('meta_analysis'), `meta_analysis in tool list: [${tools.join(', ')}]`); + } catch (e: any) { + assert(false, `Tool list fetch failed: ${e.message}`); + } +} + +async function testHRMeta() { + console.log('\n🔍 Test 1: HR (Hazard Ratio) Meta-Analysis — Direct R call'); + const body = { + data_source: { type: 'inline', data: HR_DATA }, + params: { data_type: 'hr', model: 'random' }, + }; + + try { + const result = await postJson(`${R_SERVICE_URL}/api/v1/skills/${R_TOOL_CODE}`, body); + + assert(result.status === 'success', `Status: ${result.status}`); + assert(result.results != null, 'Results object exists'); + + if (result.results) { + const r = result.results; + assert(r.k_studies === 5, `k_studies = ${r.k_studies} (expected 5)`); + assert(r.effect_measure === 'HR', `effect_measure = ${r.effect_measure}`); + assert(r.pooled_effect > 0 && r.pooled_effect < 1, `Pooled HR = ${r.pooled_effect} (0 < HR < 1, favors treatment)`); + assert(r.pooled_lower > 0 && r.pooled_lower < 1, `Lower CI = ${r.pooled_lower} (> 0)`); + assert(r.pooled_upper > 0 && r.pooled_upper < 1, `Upper CI = ${r.pooled_upper} (< 1 = significant)`); + assert(typeof r.i_squared === 'number', `I² = ${r.i_squared}%`); + assert(typeof r.tau_squared === 'number', `τ² = ${r.tau_squared}`); + assert(r.model === 'Random Effects', `Model: ${r.model}`); + console.log(` Summary: HR = ${r.pooled_effect} [${r.pooled_lower}, ${r.pooled_upper}], I² = ${r.i_squared}%`); + } + + assert(Array.isArray(result.plots) && result.plots.length >= 2, `Plots count: ${result.plots?.length}`); + if (result.plots?.[0]) { + assert(result.plots[0].startsWith('data:image/png;base64,'), 'Forest plot is base64 PNG'); + console.log(` Forest plot size: ${Math.round(result.plots[0].length / 1024)} KB`); + } + if (result.plots?.[1]) { + assert(result.plots[1].startsWith('data:image/png;base64,'), 'Funnel plot is base64 PNG'); + console.log(` Funnel plot size: ${Math.round(result.plots[1].length / 1024)} KB`); + } + + assert(Array.isArray(result.report_blocks), `Report blocks: ${result.report_blocks?.length}`); + assert(Array.isArray(result.trace_log), `Trace log entries: ${result.trace_log?.length}`); + } catch (e: any) { + assert(false, `HR meta-analysis failed: ${e.message}`); + } +} + +async function testDichotomousMeta() { + console.log('\n🔍 Test 2: Dichotomous (OR) Meta-Analysis — Direct R call'); + const body = { + data_source: { type: 'inline', data: DICHOTOMOUS_DATA }, + params: { data_type: 'dichotomous', model: 'random', effect_measure: 'OR' }, + }; + + try { + const result = await postJson(`${R_SERVICE_URL}/api/v1/skills/${R_TOOL_CODE}`, body); + + assert(result.status === 'success', `Status: ${result.status}`); + + if (result.results) { + const r = result.results; + assert(r.k_studies === 4, `k_studies = ${r.k_studies} (expected 4)`); + assert(r.effect_measure === 'OR', `effect_measure = ${r.effect_measure}`); + assert(r.pooled_effect > 0 && r.pooled_effect < 1, `Pooled OR = ${r.pooled_effect} (0 < OR < 1, favors treatment)`); + assert(typeof r.pooled_pvalue === 'number', `P-value = ${r.pooled_pvalue}`); + console.log(` Summary: OR = ${r.pooled_effect} [${r.pooled_lower}, ${r.pooled_upper}], p = ${r.pooled_pvalue}`); + } + + assert(Array.isArray(result.plots) && result.plots.length >= 2, 'Has forest + funnel plots'); + } catch (e: any) { + assert(false, `Dichotomous meta-analysis failed: ${e.message}`); + } +} + +async function testContinuousMeta() { + console.log('\n🔍 Test 3: Continuous (MD) Meta-Analysis — Direct R call'); + const body = { + data_source: { type: 'inline', data: CONTINUOUS_DATA }, + params: { data_type: 'continuous', model: 'random' }, + }; + + try { + const result = await postJson(`${R_SERVICE_URL}/api/v1/skills/${R_TOOL_CODE}`, body); + + assert(result.status === 'success', `Status: ${result.status}`); + + if (result.results) { + const r = result.results; + assert(r.k_studies === 5, `k_studies = ${r.k_studies} (expected 5)`); + assert(r.effect_measure === 'MD', `effect_measure = ${r.effect_measure}`); + assert(r.pooled_effect < 0, `Pooled MD = ${r.pooled_effect} (< 0 = treatment lower)`); + assert(typeof r.i_squared === 'number', `I² = ${r.i_squared}%`); + console.log(` Summary: MD = ${r.pooled_effect} [${r.pooled_lower}, ${r.pooled_upper}], I² = ${r.i_squared}%`); + } + + assert(Array.isArray(result.plots) && result.plots.length >= 2, 'Has forest + funnel plots'); + } catch (e: any) { + assert(false, `Continuous meta-analysis failed: ${e.message}`); + } +} + +async function testFixedEffectModel() { + console.log('\n🔍 Test 4: Fixed Effect Model'); + const body = { + data_source: { type: 'inline', data: HR_DATA }, + params: { data_type: 'hr', model: 'fixed' }, + }; + + try { + const result = await postJson(`${R_SERVICE_URL}/api/v1/skills/${R_TOOL_CODE}`, body); + assert(result.status === 'success', `Status: ${result.status}`); + if (result.results) { + assert(result.results.model === 'Fixed Effect', `Model: ${result.results.model}`); + } + } catch (e: any) { + assert(false, `Fixed effect test failed: ${e.message}`); + } +} + +async function testInsufficientData() { + console.log('\n🔍 Test 5: Edge case — Only 1 study (should error)'); + const body = { + data_source: { type: 'inline', data: [HR_DATA[0]] }, + params: { data_type: 'hr', model: 'random' }, + }; + + try { + const result = await postJson(`${R_SERVICE_URL}/api/v1/skills/${R_TOOL_CODE}`, body); + assert(result.status === 'error', `Status should be error: ${result.status}`); + console.log(` Error message: ${result.message || result.error_code}`); + } catch (e: any) { + assert(false, `Insufficient data test failed: ${e.message}`); + } +} + +async function testBackendProxy() { + console.log('\n🔍 Test 6: Backend API Proxy (/api/v1/asl/meta-analysis/run)'); + console.log(' ⚠️ Requires backend running on port 3000 with valid auth token'); + + try { + const healthRes = await fetch(`${BACKEND_URL}/api/v1/asl/meta-analysis/health`, { + headers: { 'Authorization': 'Bearer test' }, + }); + if (healthRes.status === 401) { + console.log(' ⏭️ Skipped: Backend requires authentication (expected in dev)'); + return; + } + const healthData = await healthRes.json(); + assert(healthData.rServiceAvailable === true, `R service reachable from backend: ${healthData.rServiceAvailable}`); + } catch (e: any) { + console.log(` ⏭️ Skipped: Backend not running (${e.message})`); + } +} + +async function testDichotomousRR() { + console.log('\n🔍 Test 7: Dichotomous with RR effect measure'); + const body = { + data_source: { type: 'inline', data: DICHOTOMOUS_DATA }, + params: { data_type: 'dichotomous', model: 'random', effect_measure: 'RR' }, + }; + + try { + const result = await postJson(`${R_SERVICE_URL}/api/v1/skills/${R_TOOL_CODE}`, body); + assert(result.status === 'success', `Status: ${result.status}`); + if (result.results) { + assert(result.results.effect_measure === 'RR', `effect_measure = ${result.results.effect_measure}`); + assert(result.results.pooled_effect > 0 && result.results.pooled_effect < 1, `Pooled RR = ${result.results.pooled_effect} (0 < RR < 1)`); + console.log(` Summary: RR = ${result.results.pooled_effect} [${result.results.pooled_lower}, ${result.results.pooled_upper}]`); + } + } catch (e: any) { + assert(false, `Dichotomous RR test failed: ${e.message}`); + } +} + +// ==================== Runner ==================== + +async function main() { + console.log('═══════════════════════════════════════════════════════'); + console.log(' Meta Analysis Engine — End-to-End Test Suite'); + console.log('═══════════════════════════════════════════════════════'); + console.log(` R Service: ${R_SERVICE_URL}`); + console.log(` Backend: ${BACKEND_URL}`); + console.log(` Time: ${new Date().toISOString()}`); + + const startTime = Date.now(); + + await testRHealth(); + await testRToolList(); + await testHRMeta(); + await testDichotomousMeta(); + await testContinuousMeta(); + await testFixedEffectModel(); + await testInsufficientData(); + await testDichotomousRR(); + await testBackendProxy(); + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + + console.log('\n═══════════════════════════════════════════════════════'); + console.log(` Results: ${passed} passed, ${failed} failed (${elapsed}s)`); + console.log('═══════════════════════════════════════════════════════'); + + if (failed > 0) { + process.exit(1); + } +} + +main().catch(err => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/backend/src/modules/asl/meta-analysis/controllers/MetaAnalysisController.ts b/backend/src/modules/asl/meta-analysis/controllers/MetaAnalysisController.ts new file mode 100644 index 00000000..d029b860 --- /dev/null +++ b/backend/src/modules/asl/meta-analysis/controllers/MetaAnalysisController.ts @@ -0,0 +1,42 @@ +/** + * Meta Analysis Controller + */ + +import { FastifyRequest, FastifyReply } from 'fastify'; +import { metaAnalysisService } from '../services/MetaAnalysisService.js'; +import type { MetaAnalysisRequest } from '../services/MetaAnalysisService.js'; + +export async function runAnalysis( + request: FastifyRequest<{ Body: MetaAnalysisRequest }>, + reply: FastifyReply, +) { + const { data, params } = request.body; + + if (!Array.isArray(data) || data.length < 2) { + return reply.status(400).send({ error: '至少需要 2 条研究数据' }); + } + + if (!params?.data_type) { + return reply.status(400).send({ error: '缺少 data_type 参数' }); + } + + const result = await metaAnalysisService.runAnalysis({ data, params }); + return reply.send(result); +} + +export async function getProjectData( + request: FastifyRequest<{ Params: { projectId: string } }>, + reply: FastifyReply, +) { + const { projectId } = request.params; + const { rows, detectedType } = await metaAnalysisService.getProjectExtractionData(projectId); + return reply.send({ rows, detectedType, count: rows.length }); +} + +export async function healthCheck( + _request: FastifyRequest, + reply: FastifyReply, +) { + const ok = await metaAnalysisService.healthCheck(); + return reply.send({ rServiceAvailable: ok }); +} diff --git a/backend/src/modules/asl/meta-analysis/routes/index.ts b/backend/src/modules/asl/meta-analysis/routes/index.ts new file mode 100644 index 00000000..1e646cb2 --- /dev/null +++ b/backend/src/modules/asl/meta-analysis/routes/index.ts @@ -0,0 +1,16 @@ +/** + * Meta Analysis Routes + */ + +import { FastifyInstance } from 'fastify'; +import * as ctrl from '../controllers/MetaAnalysisController.js'; +import { authenticate, requireModule } from '../../../../common/auth/auth.middleware.js'; + +export async function metaAnalysisRoutes(fastify: FastifyInstance) { + fastify.addHook('preHandler', authenticate); + fastify.addHook('preHandler', requireModule('ASL')); + + fastify.post('/run', ctrl.runAnalysis); + fastify.get('/project-data/:projectId', ctrl.getProjectData); + fastify.get('/health', ctrl.healthCheck); +} diff --git a/backend/src/modules/asl/meta-analysis/services/MetaAnalysisService.ts b/backend/src/modules/asl/meta-analysis/services/MetaAnalysisService.ts new file mode 100644 index 00000000..b6950fc0 --- /dev/null +++ b/backend/src/modules/asl/meta-analysis/services/MetaAnalysisService.ts @@ -0,0 +1,192 @@ +/** + * Meta Analysis Service + * + * Proxies meta-analysis requests to R Docker (ST_META_ANALYSIS). + * Uses inline data transfer for small datasets typical in meta-analysis (5-30 rows). + * Also supports extracting data from Tool 3 extraction results. + */ + +import axios, { AxiosInstance } from 'axios'; +import { logger } from '../../../../common/logging/index.js'; +import { prisma } from '../../../../config/database.js'; + +export type MetaDataType = 'hr' | 'dichotomous' | 'continuous'; +export type MetaModel = 'random' | 'fixed'; + +export interface MetaAnalysisParams { + data_type: MetaDataType; + model?: MetaModel; + effect_measure?: string; +} + +export interface MetaAnalysisRequest { + data: Record[]; + params: MetaAnalysisParams; +} + +const R_TOOL_CODE = 'ST_META_ANALYSIS'; + +class MetaAnalysisService { + private client: AxiosInstance; + + constructor() { + const baseURL = process.env.R_SERVICE_URL || 'http://localhost:8082'; + this.client = axios.create({ + baseURL, + timeout: 120_000, + headers: { 'Content-Type': 'application/json' }, + }); + } + + async runAnalysis(req: MetaAnalysisRequest) { + const startTime = Date.now(); + + const requestBody = { + data_source: { + type: 'inline', + data: req.data, + }, + params: req.params, + }; + + logger.info('[META] Calling R service', { + toolCode: R_TOOL_CODE, + studyCount: req.data.length, + dataType: req.params.data_type, + model: req.params.model ?? 'random', + }); + + try { + const response = await this.client.post( + `/api/v1/skills/${R_TOOL_CODE}`, + requestBody, + ); + + const executionMs = Date.now() - startTime; + + logger.info('[META] R service response', { + status: response.data?.status, + executionMs, + }); + + return { ...response.data, executionMs }; + } catch (error: any) { + const statusCode = error.response?.status; + if (statusCode === 502 || statusCode === 504) { + throw new Error('R 统计服务繁忙或超时,请稍后重试'); + } + const userHint = error.response?.data?.user_hint; + if (userHint) throw new Error(userHint); + throw new Error(`R service error: ${error.message}`); + } + } + + /** + * Fetch extraction results from a project and transform to meta-analysis data. + * Only approved results with extractedData are included. + */ + async getProjectExtractionData(projectId: string): Promise<{ + rows: Record[]; + detectedType: MetaDataType | null; + }> { + const results = await prisma.aslExtractionResult.findMany({ + where: { + projectId, + reviewStatus: 'approved', + status: 'completed', + }, + select: { + id: true, + extractedData: true, + snapshotFilename: true, + }, + }); + + if (results.length === 0) { + return { rows: [], detectedType: null }; + } + + const rows: Record[] = []; + let detectedType: MetaDataType | null = null; + + for (const r of results) { + const flat = flattenExtractedData(r.extractedData); + if (!flat) continue; + + const row: Record = { + study_id: flat.study_id || r.snapshotFilename || r.id, + }; + + if (flat.hr !== undefined) { + detectedType = detectedType ?? 'hr'; + row.hr = parseNum(flat.hr); + row.lower_ci = parseNum(flat.lower_ci ?? flat.ci_lower ?? flat.hr_lower); + row.upper_ci = parseNum(flat.upper_ci ?? flat.ci_upper ?? flat.hr_upper); + } else if (flat.events_e !== undefined || flat.event_e !== undefined) { + detectedType = detectedType ?? 'dichotomous'; + row.events_e = parseNum(flat.events_e ?? flat.event_e); + row.total_e = parseNum(flat.total_e ?? flat.n_e); + row.events_c = parseNum(flat.events_c ?? flat.event_c); + row.total_c = parseNum(flat.total_c ?? flat.n_c); + } else if (flat.mean_e !== undefined) { + detectedType = detectedType ?? 'continuous'; + row.mean_e = parseNum(flat.mean_e); + row.sd_e = parseNum(flat.sd_e); + row.n_e = parseNum(flat.n_e); + row.mean_c = parseNum(flat.mean_c); + row.sd_c = parseNum(flat.sd_c); + row.n_c = parseNum(flat.n_c); + } else { + continue; + } + + rows.push(row); + } + + return { rows, detectedType }; + } + + async healthCheck(): Promise { + try { + const res = await this.client.get('/health'); + return res.data.status === 'ok'; + } catch { + return false; + } + } +} + +function parseNum(val: unknown): number | undefined { + if (val === undefined || val === null || val === '') return undefined; + const n = Number(val); + return Number.isFinite(n) ? n : undefined; +} + +function flattenExtractedData(raw: unknown): Record | null { + if (!raw || typeof raw !== 'object') return null; + + const data = raw as Record; + const flat: Record = {}; + + for (const [moduleKey, moduleVal] of Object.entries(data)) { + if (Array.isArray(moduleVal)) { + for (const item of moduleVal) { + if (item && typeof item === 'object' && 'key' in item) { + flat[item.key] = item.value ?? item; + } + } + } else if (moduleVal && typeof moduleVal === 'object') { + for (const [k, v] of Object.entries(moduleVal as Record)) { + if (v && typeof v === 'object' && 'value' in v) { + flat[k] = v.value; + } else { + flat[k] = v; + } + } + } + } + + return Object.keys(flat).length > 0 ? flat : null; +} + +export const metaAnalysisService = new MetaAnalysisService(); diff --git a/backend/src/modules/asl/routes/index.ts b/backend/src/modules/asl/routes/index.ts index 871320d3..7aa15ac6 100644 --- a/backend/src/modules/asl/routes/index.ts +++ b/backend/src/modules/asl/routes/index.ts @@ -10,6 +10,8 @@ import * as fulltextScreeningController from '../fulltext-screening/controllers/ import * as researchController from '../controllers/researchController.js'; import * as deepResearchController from '../controllers/deepResearchController.js'; import { extractionRoutes } from '../extraction/routes/index.js'; +import { chartingRoutes } from '../charting/routes/index.js'; +import { metaAnalysisRoutes } from '../meta-analysis/routes/index.js'; import { authenticate, requireModule } from '../../../common/auth/auth.middleware.js'; export async function aslRoutes(fastify: FastifyInstance) { @@ -111,6 +113,12 @@ export async function aslRoutes(fastify: FastifyInstance) { // ==================== 工具 3:全文智能提取路由 ==================== await fastify.register(extractionRoutes, { prefix: '/extraction' }); + + // ==================== 工具 4:SR 图表生成器路由 ==================== + await fastify.register(chartingRoutes, { prefix: '/charting' }); + + // ==================== 工具 5:Meta 分析引擎路由 ==================== + await fastify.register(metaAnalysisRoutes, { prefix: '/meta-analysis' }); } diff --git a/backend/src/modules/iit-manager/routes/index.ts b/backend/src/modules/iit-manager/routes/index.ts index 4873f81b..60971d74 100644 --- a/backend/src/modules/iit-manager/routes/index.ts +++ b/backend/src/modules/iit-manager/routes/index.ts @@ -463,12 +463,57 @@ export async function registerIitRoutes(fastify: FastifyInstance) { logger.info('Registered route: POST /api/v1/iit/patient-wechat/callback'); - // TODO: 后续添加其他路由 - // - 项目管理路由 - // - 影子状态路由 - // - 任务管理路由 - // - 患者绑定路由 - // - 患者信息查询路由 + // ============================================= + // Web Chat API(业务端 AI 对话) + // ============================================= + const { getChatOrchestrator } = await import('../services/ChatOrchestrator.js'); + + fastify.post( + '/api/v1/iit/chat', + { + schema: { + body: { + type: 'object', + required: ['message'], + properties: { + message: { type: 'string' }, + userId: { type: 'string' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + reply: { type: 'string' }, + duration: { type: 'number' }, + }, + }, + }, + }, + }, + async (request: any, reply) => { + const startTime = Date.now(); + try { + const { message, userId } = request.body; + const uid = userId || request.user?.id || 'web-user'; + const orchestrator = await getChatOrchestrator(); + const replyText = await orchestrator.handleMessage(uid, message); + + return reply.code(200).send({ + reply: replyText, + duration: Date.now() - startTime, + }); + } catch (error: any) { + logger.error('Web chat failed', { error: error.message }); + return reply.code(500).send({ + reply: '系统处理出错,请稍后重试。', + duration: Date.now() - startTime, + }); + } + } + ); + + logger.info('Registered route: POST /api/v1/iit/chat'); } diff --git a/docs/00-系统总体设计/00-系统当前状态与开发指南.md b/docs/00-系统总体设计/00-系统当前状态与开发指南.md index 5d3b0755..1d00d918 100644 --- a/docs/00-系统总体设计/00-系统当前状态与开发指南.md +++ b/docs/00-系统总体设计/00-系统当前状态与开发指南.md @@ -1,12 +1,13 @@ # AIclinicalresearch 系统当前状态与开发指南 -> **文档版本:** v6.2 +> **文档版本:** v6.3 > **创建日期:** 2025-11-28 > **维护者:** 开发团队 > **最后更新:** 2026-02-26 > **🎉 重大里程碑:** +> - **🆕 2026-02-26:ASL 工具 4 SR 图表生成器 + 工具 5 Meta 分析引擎开发完成!** PRISMA 流程图(中英切换)+ 基线特征表 + Meta 分析(HR/二分类/连续型)+ 森林图/漏斗图 + R Docker meta 包 + E2E 36/36 通过 > - **🆕 2026-02-26:CRA Agent V3.0 P0+P1 全部完成!** 自驱动质控流水线 + ChatOrchestrator + LLM Function Calling + E2E 54/54 通过 -> - **🆕 2026-02-24:ASL 工具 3 V2.0 架构升级至散装派发 + Aggregator!** 9 条研发红线 + 散装派发与轮询收口任务模式指南 v1.1 沉淀 +> - **2026-02-24:ASL 工具 3 V2.0 架构升级至散装派发 + Aggregator!** 9 条研发红线 + 散装派发与轮询收口任务模式指南 v1.1 沉淀 > - **2026-02-23:ASL 工具 3 V2.0 开发计划完成!** HITL + 动态模板 + M1/M2/M3 三阶段 22 天 > - **🆕 2026-02-23:ASL Deep Research V2.0 核心功能完成!** SSE 实时流 + 段落化思考 + 瀑布流 UI + Markdown 渲染 + 引用链接可见 + Word 导出 + 中文数据源 > - **🆕 2026-02-22:SSA Phase I-IV 开发完成!** Session 黑板 + 对话层 LLM + 方法咨询 + 对话驱动分析,E2E 107/107 通过 @@ -29,17 +30,13 @@ > - **2026-01-24:Protocol Agent 框架完成!** 可复用Agent框架+5阶段对话流程 > - **2026-01-22:OSS 存储集成完成!** 阿里云 OSS 正式接入平台基础层 > -> **🆕 最新进展(ASL 工具 3 架构升级 + V2.0 核心完成 2026-02-24):** -> - 📋 **🆕 ASL 工具 3 V2.0 架构升级至散装派发 + Aggregator** — 废弃 Fan-out,采用散装模式(v2.0 定稿,9 条研发红线,M1/M2/M3 三阶段 22 天) -> - 📋 **🆕 散装派发与轮询收口任务模式指南 v1.1** — Postgres-Only 合规的 Level 2 批量任务处理模式(API 散装 → N Worker → Aggregator 收口) -> - 📋 **分布式 Fan-out 任务模式开发指南** — Level 3 参考(8 模式 + 18 反模式,已不用于 ASL 工具 3) -> - ✅ **🎉 ASL Deep Research V2.0 核心功能完成** — SSE 流式架构 + 瀑布流 UI + HITL + 5 精选数据源 + Word 导出 -> - ✅ **SSE 流式替代轮询** — 实时推送 AI 思考过程(reasoning_content),段落化日志聚合 -> - ✅ **Markdown 渲染 + 引用链接可见化** — react-markdown 正确渲染报告,`[6]` 后显示完整 URL -> - ✅ **中文数据源专项测试** — CNKI/中华医学期刊网 domain_scope 有效,混合源建议分批搜索 -> - ✅ **DeepSearch 通用能力指南 v2.0** — `docs/02-通用能力层/04-DeepResearch引擎/` -> - ✅ **🎉 SSA Phase I-IV 全部开发完成** — Session 黑板 + 意图路由器 + 对话层 LLM + 方法咨询 + AskUser 标准化 + 对话驱动分析 + QPER 集成 -> - ✅ **SSA E2E 测试全部通过** — Phase I 31/31 + Phase II 38/38 + Phase III 13/13 + Phase IV 25/25 = 共 107 项 +> **🆕 最新进展(ASL 工具 4+5 开发完成 2026-02-26):** +> - ✅ **🎉🆕 ASL 工具 4 SR 图表生成器完成** — PRISMA 2020 流程图(中英切换)+ 基线特征表 + 双通道数据源(项目流水线/Excel)+ SVG/PNG 导出 +> - ✅ **🎉🆕 ASL 工具 5 Meta 分析引擎完成** — HR/二分类/连续型 3 种数据类型 + 随机/固定效应模型 + 森林图/漏斗图 + R Docker meta 包 + E2E 36/36 通过 +> - ✅ **🆕 R Docker 镜像更新** — 新增 meta 包,工具总数从 12 增至 13(ST_META_ANALYSIS) +> - ✅ **🎉 ASL 工具 3 M1+M2 开发完成** — 散装派发+Aggregator + MinerU VLM + XML Prompt + HITL 审核抽屉 + Excel 导出 +> - ✅ **🎉 SSA Phase I-IV 全部开发完成** — Session 黑板 + 意图路由器 + 对话层 LLM + 方法咨询 + QPER 集成,E2E 107/107 +> - ✅ **🎉 CRA Agent V3.0 P0+P1 完成** — ChatOrchestrator + LLM Function Calling + 4 工具 + E2E 54/54 > > **部署状态:** ✅ 生产环境运行中 | 公网地址:http://8.140.53.236/ > **REDCap 状态:** ✅ 生产环境运行中 | 地址:https://redcap.xunzhengyixue.com/ @@ -77,7 +74,7 @@ |---------|---------|---------|---------|---------|--------| | **AIA** | AI智能问答 | 12个智能体 + Protocol Agent(全流程方案) | ⭐⭐⭐⭐⭐ | 🎉 **V3.1 MVP完整交付(90%)** - 一键生成+Word导出 | **P0** | | **PKB** | 个人知识库 | RAG问答、私人文献库 | ⭐⭐⭐ | 🎉 **Dify已替换!自研RAG上线(95%)** | P1 | -| **ASL** | AI智能文献 | 文献筛选、Deep Research、全文智能提取 | ⭐⭐⭐⭐⭐ | 🎉 **V2.0 核心完成(80%)+ 🆕工具3计划v2.0就绪** - SSE流式+瀑布流UI+HITL+Word导出+散装派发+Aggregator+动态模板 | **P0** | +| **ASL** | AI智能文献 | 文献筛选、Deep Research、全文智能提取、SR图表、Meta分析 | ⭐⭐⭐⭐⭐ | 🎉 **V2.0 核心完成(90%)+ 🆕工具4+5完成** - SSE流式+瀑布流UI+HITL+Word导出+散装派发+PRISMA流程图+Meta分析引擎(R Docker) | **P0** | | **DC** | 数据清洗整理 | ETL + 医学NER(百万行级数据) | ⭐⭐⭐⭐⭐ | ✅ **Tool B完成 + Tool C 99%(异步架构+性能优化-99%+多指标转换+7大功能)** | **P0** | | **IIT** | IIT Manager Agent | CRA Agent - LLM Tool Use + 自驱动质控 + 统一驾驶舱 | ⭐⭐⭐⭐⭐ | 🎉 **V3.0 P0+P1完成!** ChatOrchestrator + 4工具 + E2E 54/54 | **P1-2** | | **SSA** | 智能统计分析 | **QPER架构** + 四层七工具 + 对话层LLM + 意图路由器 | ⭐⭐⭐⭐⭐ | 🎉 **Phase I-IV 开发完成** — QPER闭环 + Session黑板 + 意图路由 + 对话LLM + 方法咨询 + 对话驱动分析,E2E 107/107 | **P1** | @@ -169,9 +166,40 @@ --- -## 🚀 当前开发状态(2026-02-23) +## 🚀 当前开发状态(2026-02-26) -### 🎉 最新进展:ASL Deep Research V2.0 核心功能完成(2026-02-23) +### 🎉 最新进展:ASL 工具 4 + 工具 5 开发完成(2026-02-26) + +#### ✅ ASL 工具 4 SR 图表生成器 + 工具 5 Meta 分析引擎 + +**重大里程碑:ASL 证据整合工具链(工具 3→4→5)核心功能全部完成!** + +| 工具 | 核心功能 | 技术实现 | 状态 | +|------|---------|---------|------| +| 工具 4:SR 图表生成器 | PRISMA 2020 流程图(中英切换)| 前端 SVG 渲染 + I18N | ✅ | +| 工具 4:SR 图表生成器 | 基线特征表(Table 1) | Ant Design 动态列 | ✅ | +| 工具 4:SR 图表生成器 | 双通道数据源 | 项目流水线 API + Excel 上传 | ✅ | +| 工具 5:Meta 分析引擎 | HR / 二分类 / 连续型 | R meta 包(metagen/metabin/metacont) | ✅ | +| 工具 5:Meta 分析引擎 | 森林图 + 漏斗图 | R base64 PNG | ✅ | +| 工具 5:Meta 分析引擎 | 异质性统计量 | I²、Q、p、Tau² | ✅ | +| 工具 5:Meta 分析引擎 | 数据输入 | Excel 上传 + 项目流水线 | ✅ | +| 工具 5:Meta 分析引擎 | E2E 测试 | 36/36 通过(HR/OR/RR/连续型/边界情况) | ✅ | + +**关键技术决策**: +- ✅ **R Docker 集成**:meta 包安装到 R Docker 镜像,Brain-Hand 架构复用 SSA 基础设施 +- ✅ **Log 尺度回转**:meta 包返回 log 尺度 HR/OR/RR,R 脚本内 exp() 回转 +- ✅ **Excel 优先输入**:用户下载 Excel 模板编辑数据后上传,避免浏览器内编辑表格的复杂度 +- ✅ **PRISMA 中英切换**:I18N 对象驱动,默认中文(面向国内医生),英文用于 SCI 论文 + +**新增 API 端点**:5 个(工具 4: 2 个 + 工具 5: 3 个) +**新增前端路由**:2 个(`/charting` + `/meta-analysis`) +**R Docker 更新**:meta 包新增,工具总数 13 + +**相关文档**: +- 模块状态:`docs/03-业务模块/ASL-AI智能文献/00-模块当前状态与开发指南.md` +- R 引擎指南:`docs/02-通用能力层/06-R统计引擎/01-R统计引擎架构与部署指南.md` + +### 🎉 ASL Deep Research V2.0 核心功能完成(2026-02-23) #### ✅ ASL Deep Research V2.0 核心开发完成 @@ -1510,7 +1538,7 @@ npm run dev # http://localhost:3000 ### 模块完成度 - ✅ **已完成**:AIA V2.0(85%,核心功能完成)、平台基础层(100%)、RVW(95%)、通用能力层升级(100%)、**PKB(95%,Dify已替换)** 🎉 -- 🚧 **开发中**:**ASL(80%,🎉 V2.0 核心功能完成:SSE流式+瀑布流UI+HITL+Word导出+中文数据源)**、DC(Tool C 98%,Tool B后端100%,Tool B前端0%)、IIT(60%,Phase 1.5完成)、**SSA(QPER主线100% + Phase I-IV 全部完成,E2E 107/107,Phase VI 待启动)** 🎉 +- 🚧 **开发中**:**ASL(90%,🎉 工具3 M1+M2 + 工具4 + 工具5 完成:PRISMA图+Meta分析+R Docker)**、DC(Tool C 98%,Tool B后端100%,Tool B前端0%)、IIT(60%,Phase 1.5完成)、**SSA(QPER主线100% + Phase I-IV 全部完成,E2E 107/107,Phase VI 待启动)** 🎉 - 📋 **未开始**:ST ### 部署完成度 @@ -1660,9 +1688,9 @@ if (items.length >= 50) { --- -**文档版本**:v6.1 -**最后更新**:2026-02-23 -**本次更新**:ASL Deep Research V2.0 核心功能完成(SSE流式+段落化思考+引用链接可见化+瀑布流UI+Word导出+中文数据源) +**文档版本**:v6.3 +**最后更新**:2026-02-26 +**本次更新**:ASL 工具 4 SR 图表生成器 + 工具 5 Meta 分析引擎开发完成(PRISMA 中英切换 + Meta 分析 HR/二分类/连续型 + R Docker meta 包 + E2E 36/36) --- diff --git a/docs/02-通用能力层/06-R统计引擎/01-R统计引擎架构与部署指南.md b/docs/02-通用能力层/06-R统计引擎/01-R统计引擎架构与部署指南.md index e4f8e420..79ef864b 100644 --- a/docs/02-通用能力层/06-R统计引擎/01-R统计引擎架构与部署指南.md +++ b/docs/02-通用能力层/06-R统计引擎/01-R统计引擎架构与部署指南.md @@ -1,9 +1,9 @@ # R 统计引擎架构与部署指南 -> **版本:** v1.3 -> **更新日期:** 2026-02-22 -> **维护者:** SSA-Pro 开发团队 -> **状态:** ✅ 生产就绪(Phase Deploy 完成 — 12 工具 + Block-based 标准化输出) +> **版本:** v1.4 +> **更新日期:** 2026-02-26 +> **维护者:** SSA-Pro 开发团队 / ASL 循证工具箱团队 +> **状态:** ✅ 生产就绪(13 工具 + Block-based 标准化输出 — 新增 Meta 分析引擎) --- @@ -42,11 +42,11 @@ R 统计引擎是平台的**专用统计计算服务**,基于 Docker 容器化 ┌─────────────────────────────────────────────────────────────┐ │ 业务模块层 │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ -│ │ SSA-Pro │ │ 其他 │ │ 其他 │ │ -│ │ 智能统计 │ │ 模块 │ │ 模块 │ │ -│ └────┬────┘ └─────────┘ └─────────┘ │ -├───────┼─────────────────────────────────────────────────────┤ -│ ▼ 通用能力层 │ +│ │ SSA-Pro │ │ ASL │ │ 其他 │ │ +│ │ 智能统计 │ │Meta分析 │ │ 模块 │ │ +│ └────┬────┘ └────┬────┘ └─────────┘ │ +├───────┼──────────┼──────────────────────────────────────────┤ +│ ▼ ▼ 通用能力层 │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ R 统计引擎 (Docker) │ │ │ │ • /health 健康检查 │ │ @@ -68,6 +68,7 @@ R 统计引擎是平台的**专用统计计算服务**,基于 Docker 容器化 | gtsummary | 最新 | 基线特征表生成(Phase Deploy 新增) | | gt/broom | 最新 | 表格渲染/模型整理(Phase Deploy 新增) | | scales/gridExtra | 最新 | 坐标轴格式化/多图排版(Phase Deploy 新增) | +| meta | 8.2.1 | Meta 分析引擎(ASL 工具 5 新增 — metagen/metabin/metacont) | | Docker | 24+ | 容器化部署 | --- @@ -187,7 +188,7 @@ RUN apt-get update && apt-get install -y \ curl \ && rm -rf /var/lib/apt/lists/* -# 直接安装 R 包(含 Phase Deploy 新增依赖) +# 直接安装 R 包(含 Phase Deploy + ASL Meta 分析依赖) RUN R -e "install.packages(c( \ 'plumber', \ 'jsonlite', \ @@ -203,7 +204,8 @@ RUN R -e "install.packages(c( \ 'gridExtra', \ 'gtsummary', \ 'gt', \ - 'broom' \ + 'broom', \ + 'meta' \ ), repos='https://cloud.r-project.org/', Ncpus=2)" # 安全加固:创建非特权用户 @@ -273,8 +275,8 @@ ssa-r-statistics 1.0.1 xxxxxxxxxxxx x minutes ago 1.81GB |------|------| | 基础镜像下载 | ~2 分钟(首次) | | 系统依赖安装 | ~1 分钟 | -| R 包安装(15 个包含 gtsummary/gt) | ~10 分钟 | -| **总计** | **~13 分钟** | +| R 包安装(16 个,含 gtsummary/gt/meta) | ~15 分钟 | +| **总计** | **~17 分钟** | --- @@ -378,7 +380,7 @@ GET /health "timestamp": "2026-02-19 08:00:00", "version": "1.0.1", "dev_mode": true, - "tools_loaded": 1 + "tools_loaded": 13 } ``` @@ -402,15 +404,16 @@ GET /api/v1/tools "linear_reg", "logistic_binary", "mann_whitney", + "meta_analysis", "t_test_ind", "t_test_paired", "wilcoxon" ], - "count": 12 + "count": 13 } ``` -#### 已实现的统计工具(12 个) +#### 已实现的统计工具(13 个) **Phase 2A 基础工具(7 个)** @@ -434,6 +437,14 @@ GET /api/v1/tools | `ST_LINEAR_REG` | 线性回归 | 连续结局多因素分析 | | `ST_BASELINE_TABLE` | 基线特征表(复合工具) | 基于 gtsummary 的一键式基线表生成 | +**ASL 循证工具箱新增(1 个)** + +| tool_code | 名称 | 场景 | 调用方 | +|-----------|------|------|--------| +| `ST_META_ANALYSIS` | Meta 分析引擎 | HR / 二分类 / 连续型 Meta 分析,生成森林图 + 漏斗图 | ASL 工具 5 | + +> **跨模块复用说明:** `ST_META_ANALYSIS` 由 ASL 模块(工具 5:Meta 分析引擎)引入,后端代理位于 `backend/src/modules/asl/meta-analysis/`,使用 `inline` 数据协议(Meta 分析通常 5-30 个研究,数据量极小)。该工具同样可被 SSA 或其他模块复用。 + ### 5.3 执行技能 ```http @@ -593,6 +604,79 @@ Content-Type: application/json > **特点:** `ST_BASELINE_TABLE` 是复合工具,基于 `gtsummary::tbl_summary()` 自动判断变量类型(连续/分类)、选择统计方法(T 检验/Mann-Whitney/卡方/Fisher),输出标准三线表。`report_blocks[0].metadata.is_baseline_table = true` 触发前端特殊渲染(P 值标星、rowspan 合并行)。 +### 5.6 Meta 分析示例(ASL 工具 5 — v1.4 新增) + +```http +POST /api/v1/skills/ST_META_ANALYSIS +Content-Type: application/json +``` + +**请求体(HR 数据类型):** +```json +{ + "data_source": { + "type": "inline", + "data": [ + {"study_id": "Gandhi 2018", "hr": 0.49, "lower_ci": 0.38, "upper_ci": 0.64}, + {"study_id": "Socinski 2018", "hr": 0.56, "lower_ci": 0.45, "upper_ci": 0.70}, + {"study_id": "West 2019", "hr": 0.60, "lower_ci": 0.45, "upper_ci": 0.80} + ] + }, + "params": { + "data_type": "hr", + "model": "random" + } +} +``` + +**支持的 3 种数据类型:** + +| data_type | 必需列 | R 函数 | 效应指标 | +|-----------|--------|--------|----------| +| `hr` | `study_id`, `hr`, `lower_ci`, `upper_ci` | `meta::metagen()` | HR | +| `dichotomous` | `study_id`, `events_e`, `total_e`, `events_c`, `total_c` | `meta::metabin()` | OR / RR / RD | +| `continuous` | `study_id`, `mean_e`, `sd_e`, `n_e`, `mean_c`, `sd_c`, `n_c` | `meta::metacont()` | MD | + +**params 参数:** + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `data_type` | string | 必填 | `"hr"` / `"dichotomous"` / `"continuous"` | +| `model` | string | `"random"` | `"random"` (DerSimonian-Laird) / `"fixed"` | +| `effect_measure` | string | 自动 | 仅二分类有效:`"OR"` / `"RR"` / `"RD"` | + +**成功响应(核心字段):** +```json +{ + "status": "success", + "results": { + "pooled_effect": 0.5948, + "pooled_lower": 0.5255, + "pooled_upper": 0.6733, + "pooled_pvalue": 0.0, + "i_squared": 15.4, + "tau_squared": 0.0031, + "q_statistic": 4.73, + "q_pvalue": 0.316, + "k_studies": 3, + "effect_measure": "HR", + "model": "Random Effects" + }, + "plots": [ + "data:image/png;base64,...(森林图)", + "data:image/png;base64,...(漏斗图)" + ], + "report_blocks": [ + {"type": "key_value", "title": "Meta-Analysis Summary", "items": [...]}, + {"type": "image", "title": "Forest Plot", "data": "..."}, + {"type": "image", "title": "Funnel Plot", "data": "..."}, + {"type": "markdown", "title": "Heterogeneity Assessment", "content": "..."} + ] +} +``` + +> **重要实现细节 — 对数尺度反变换:** `meta` R 包对比值类效应量(HR / OR / RR)的内部计算在**对数尺度**上进行,即 `TE.random` 返回的是 `log(HR)` 而非 HR。`meta_analysis.R` 在结果提取阶段对比值类指标自动执行 `exp()` 反变换,确保 API 返回临床可读的效应量(如 HR = 0.59 而非 log(HR) = -0.52)。连续型指标(MD)无需反变换。 + --- ## 6. 开发指南 @@ -798,6 +882,7 @@ blocks[[length(blocks) + 1]] <- make_markdown_block("两组差异具有统计学 | `ST_WILCOXON` | `before_var`, `after_var` | — | | `ST_LINEAR_REG` | `outcome_var`, `predictors` (数组) | `confounders` (数组) | | `ST_BASELINE_TABLE` | `group_var` | `analyze_vars` (数组,不传则自动全选) | +| `ST_META_ANALYSIS` | `data_type` (`hr` / `dichotomous` / `continuous`) | `model` (`random` / `fixed`)、`effect_measure` (二分类: `OR` / `RR` / `RD`) | ### 6.6 R 语言陷阱速查(从实际 Bug 中总结) @@ -921,6 +1006,23 @@ cd r-statistics-service docker-compose restart ``` +#### 陷阱 8:`meta` 包返回对数尺度效应量(v1.4 新增) + +**现象:** Meta 分析返回 HR = -0.52,但期望 HR ≈ 0.59 + +**原因:** `meta` R 包的 `metagen()`/`metabin()` 对比值类效应量(HR、OR、RR)在**对数尺度**上计算。`TE.random`/`TE.fixed` 返回的是 `log(HR)` 或 `log(OR)`,而非原始比值。 + +```r +# ❌ 错误:直接返回 TE(对数尺度) +pooled_effect = ma_result$TE.random # 返回 -0.52(这是 log(HR)) + +# ✅ 正确:对比值类指标做 exp() 反变换 +is_ratio <- sm_label %in% c("HR", "OR", "RR") +display_te <- if (is_ratio) exp(pooled_te) else pooled_te # 返回 0.59(真实 HR) +``` + +**影响范围:** 仅 `meta_analysis.R`,不影响其他统计工具。连续型指标(MD)无需反变换。 + ### 6.7 开发环境新增 R 包 当新工具依赖尚未安装的 R 包时,有两种方式: @@ -1156,7 +1258,7 @@ curl -s -X POST "http://localhost:8082/api/v1/skills/ST_T_TEST_IND" \ curl -s http://localhost:8082/health | jq ``` -### 9.3 R 工具集中测试脚本(12 工具 + JIT) +### 9.3 R 工具集中测试脚本(13 工具 + JIT) 项目提供了 R 统计引擎的全工具测试脚本: @@ -1166,7 +1268,7 @@ node r-statistics-service/tests/run_all_tools_test.js ``` 测试覆盖: -- 12 个统计工具(Phase 2A × 7 + Phase Deploy × 5) +- 13 个统计工具(Phase 2A × 7 + Phase Deploy × 5 + ASL × 1) - JIT 护栏检查(ST_T_TEST_IND / ST_ANOVA_ONE / ST_FISHER / ST_LINEAR_REG) - `report_blocks` 协议校验(类型、必填字段、metadata) @@ -1180,17 +1282,44 @@ node run_e2e_test.js ``` 测试覆盖: -- Layer 1: R 服务(12 个统计工具 + JIT 护栏) +- Layer 1: R 服务(13 个统计工具 + JIT 护栏) - Layer 2: Python DataProfile API - Layer 3: Node.js 后端 API(登录 → 会话 → 规划 → 执行) +### 9.5 Meta 分析引擎专项 E2E 测试(v1.4 新增) + +覆盖 Meta 分析的完整链路(Node.js → R Docker → 森林图/漏斗图生成): + +```bash +cd backend +npx tsx src/modules/asl/meta-analysis/__tests__/meta-e2e-test.ts +``` + +测试覆盖(8 个测试项,36 个断言): +- R 服务健康检查 + meta_analysis 工具注册验证 +- HR 风险比 Meta 分析(5 个研究,随机效应) +- 二分类 OR Meta 分析(4 个研究) +- 二分类 RR Meta 分析(效应指标切换) +- 连续型 MD Meta 分析(5 个研究) +- 固定效应模型切换 +- 边界条件:仅 1 个研究(应返回错误) +- 森林图 + 漏斗图 Base64 PNG 生成验证 + +最近一次测试结果(2026-02-26): +``` +Results: 36 passed, 0 failed (1.3s) +HR = 0.5948 [0.5255, 0.6733], I² = 15.4% +OR = 0.4530 [0.3328, 0.6166], p < .001 +MD = -1.4923 [-1.9098, -1.0749], I² = 70% +``` + --- ## 附录:文件结构 ``` r-statistics-service/ -├── Dockerfile # 生产镜像定义(含 gtsummary/gt/broom/scales/gridExtra) +├── Dockerfile # 生产镜像定义(含 gtsummary/gt/broom/scales/gridExtra/meta) ├── docker-compose.yml # 开发环境编排(含 volume 挂载) ├── renv.lock # R 包版本锁定(备用) ├── .Rprofile # R 启动配置(备用) @@ -1201,7 +1330,7 @@ r-statistics-service/ │ ├── error_codes.R # 错误映射 │ ├── result_formatter.R # 结果格式化 │ └── block_helpers.R # Block-based 输出辅助函数(Phase E+ 协议) -├── tools/ # 统计工具(12 个) +├── tools/ # 统计工具(13 个) │ ├── t_test_ind.R # 独立样本 T 检验 │ ├── t_test_paired.R # 配对 T 检验 │ ├── mann_whitney.R # Mann-Whitney U 检验 @@ -1213,7 +1342,8 @@ r-statistics-service/ │ ├── anova_one.R # 🆕 单因素方差分析(Phase Deploy) │ ├── wilcoxon.R # 🆕 Wilcoxon 符号秩检验(Phase Deploy) │ ├── linear_reg.R # 🆕 线性回归(Phase Deploy) -│ └── baseline_table.R # 🆕 基线特征表 — 复合工具(Phase Deploy) +│ ├── baseline_table.R # 🆕 基线特征表 — 复合工具(Phase Deploy) +│ └── meta_analysis.R # 🆕 Meta 分析引擎 — HR/二分类/连续型(ASL 工具 5) ├── tests/ │ ├── run_all_tools_test.js # 🆕 全工具自动化测试(12 工具 + JIT + blocks 校验) │ ├── test_t_test.json # T 检验测试数据 @@ -1234,6 +1364,7 @@ r-statistics-service/ | 版本 | 日期 | 更新内容 | |------|------|----------| +| v1.4 | 2026-02-26 | ASL Meta 分析引擎:工具 12→13(+ST_META_ANALYSIS),Dockerfile 新增 `meta` 包,新增 §5.6 Meta 分析 API 示例、陷阱 8(对数尺度反变换)、§9.5 Meta E2E 测试(36 断言全通过),架构图更新 ASL 调用方 | | v1.3 | 2026-02-22 | 开发者体验增强:新工具模板补全 report_blocks(§6.1)、各工具 params 速查表(§6.5)、R 语言 7 大陷阱实录(§6.6)、新增 R 包操作指南(§6.7)、新增 Q11-Q13 常见问题 | | v1.2 | 2026-02-22 | Phase Deploy 完成:工具 7→12(+Fisher/ANOVA/Wilcoxon/线性回归/基线表)、Dockerfile 新增 gtsummary 等 5 包、Block-based 输出协议文档化(§6.4)、全工具测试脚本 | | v1.1 | 2026-02-20 | Phase 2A 完成:7 个统计工具、JIT 护栏、热重载说明、常见问题补充 | diff --git a/docs/03-业务模块/ASL-AI智能文献/00-模块当前状态与开发指南.md b/docs/03-业务模块/ASL-AI智能文献/00-模块当前状态与开发指南.md index eae2bd24..74b08245 100644 --- a/docs/03-业务模块/ASL-AI智能文献/00-模块当前状态与开发指南.md +++ b/docs/03-业务模块/ASL-AI智能文献/00-模块当前状态与开发指南.md @@ -1,11 +1,12 @@ # AI智能文献模块 - 当前状态与开发指南 -> **文档版本:** v2.2 +> **文档版本:** v2.3 > **创建日期:** 2025-11-21 > **维护者:** AI智能文献开发团队 -> **最后更新:** 2026-02-25 🆕 **工具 3 M1 骨架管线 + M2 HITL 工作台开发完成!** +> **最后更新:** 2026-02-26 🆕 **工具 4 SR 图表生成器 + 工具 5 Meta 分析引擎开发完成!** > **重大进展:** -> - 🆕 2026-02-25:**工具 3 M1+M2 开发完成!** 散装派发+Aggregator 全链路、MinerU 集成、XML Prompt 隔离、fuzzyQuoteMatch 溯源、HITL 审核抽屉、Excel 导出、数据归一化修复 +> - 🆕 2026-02-26:**工具 4 + 工具 5 开发完成!** PRISMA 流程图(中英切换)+ 基线特征表 + Meta 分析引擎(HR/二分类/连续型 + 森林图/漏斗图)+ R Docker meta 包集成 + E2E 36/36 通过 +> - 2026-02-25:**工具 3 M1+M2 开发完成!** 散装派发+Aggregator 全链路、MinerU 集成、XML Prompt 隔离、fuzzyQuoteMatch 溯源、HITL 审核抽屉、Excel 导出、数据归一化修复 > - 2026-02-24:工具 3 V2.0 架构升级!Fan-out → 散装派发 + Aggregator 轮询收口,通用模式指南 v1.1 沉淀 > - 2026-02-23:V2.0 核心功能完成!SSE 流式架构 + 段落化思考日志 + 引用链接可见化 > - 2026-02-22:V2.0 前后端联调完成!瀑布流 UI + Markdown 渲染 + Word 导出 + 中文数据源测试 @@ -32,15 +33,17 @@ AI智能文献模块是一个基于大语言模型(LLM)的文献筛选系统,用于帮助研究人员根据PICOS标准自动筛选文献。 ### 当前状态 -- **开发阶段**:🎉 工具 3 M1+M2 开发完成,M3 待启动 +- **开发阶段**:🎉 工具 3 M1+M2 + 工具 4 + 工具 5 开发完成 - **已完成功能**: - ✅ 标题摘要初筛(Title & Abstract Screening)- 完整流程 - ✅ 全文复筛后端(Day 2-5)- LLM服务 + API + Excel导出 - ✅ **智能文献检索(DeepSearch)V1.x MVP** - unifuncs API 集成 - ✅ **Unifuncs API 网站覆盖测试** - 18 站点实测,9 个一级可用 - ✅ **🎉 Deep Research V2.0 核心功能** — SSE 流式架构 + 瀑布流 UI + HITL + Word 导出 - - ✅ **🆕 工具 3 M1 骨架管线** — 散装派发+Aggregator 全链路、PKB ACL 防腐层、DeepSeek-V3 纯文本盲提、3 步极简前端 - - ✅ **🆕 工具 3 M2 HITL 工作台** — MinerU VLM 表格集成、XML Prompt 隔离、fuzzyQuoteMatch 溯源、SSE 实时日志、审核抽屉、Excel 宽表导出 + - ✅ **工具 3 M1 骨架管线** — 散装派发+Aggregator 全链路、PKB ACL 防腐层、DeepSeek-V3 纯文本盲提、3 步极简前端 + - ✅ **工具 3 M2 HITL 工作台** — MinerU VLM 表格集成、XML Prompt 隔离、fuzzyQuoteMatch 溯源、SSE 实时日志、审核抽屉、Excel 宽表导出 + - ✅ **🆕 工具 4 SR 图表生成器** — PRISMA 2020 流程图(中英切换)+ 基线特征表 + 双通道数据源(项目流水线/Excel 上传)+ SVG/PNG 导出 + - ✅ **🆕 工具 5 Meta 分析引擎** — HR/二分类/连续型 3 种数据类型 + 随机/固定效应模型 + 森林图+漏斗图 + R Docker meta 包集成 + E2E 36/36 通过 - **待开发**: - 📋 **工具 3 M3 动态模板引擎** — 自定义字段 CRUD、Prompt 注入防护、E2E 测试 - **V2.0 已完成**: @@ -245,6 +248,121 @@ GET /api/v1/asl/extraction/pkb/knowledge-bases/:kbId/documents # PKB 文档 | `extraction_tasks` | 提取任务(idempotencyKey 幂等) | | `extraction_results` | 单文档提取结果(extractedData JSON + quoteVerification + reviewStatus) | +### 🆕 工具 4 SR 图表生成器(2026-02-26 开发完成) + +**功能定位:** 基于工具 3 提取结果,一键生成 PRISMA 2020 流程图 + 基线特征表(Table 1),支持从项目流水线或 Excel 导入数据。 + +**开发状态:** ✅ 开发完成 + +**核心功能:** + +| 功能 | 技术实现 | 状态 | +|------|---------|------| +| PRISMA 2020 流程图 | SVG 渲染,支持中英文切换 | ✅ | +| 基线特征表(Table 1) | Ant Design Table 动态渲染 | ✅ | +| 双通道数据源 | 项目流水线(API 聚合)+ Excel 上传 | ✅ | +| 图表导出 | SVG / PNG 导出 | ✅ | +| Excel 模板 | 下载标准模板 + 解析上传文件 | ✅ | +| 中英文切换 | PRISMA 图中英文 I18N(默认中文) | ✅ | + +**核心架构决策:** + +| 决策 | 方案 | +|------|------| +| PRISMA 数据聚合 | 后端 Prisma `count` 聚合 aslLiterature / screeningResult / fulltextResult / extractionResult | +| 基线数据来源 | 后端查询 approved 的 extractionResult,flattenModuleData 归一化 | +| 图表渲染 | 前端 SVG 组件,无第三方图表库依赖 | +| 中英切换 | I18N 对象 + lang prop + 切换按钮组 | + +**新增 API 端点:** +```http +GET /api/v1/asl/charting/prisma-data/:projectId # PRISMA 聚合数据 +GET /api/v1/asl/charting/baseline-data/:projectId # 基线特征数据 +``` + +**新增前端路由:** +``` +/literature/charting # SR 图表生成器页面 +``` + +**核心代码组件:** + +| 组件 | 文件 | 说明 | +|------|------|------| +| 图表服务 | `charting/services/ChartingService.ts` | Prisma 聚合 + 基线数据提取 | +| 图表控制器 | `charting/controllers/ChartingController.ts` | 2 个 API 端点 | +| PRISMA 图 | `components/charting/PrismaFlowDiagram.tsx` | SVG 渲染 + I18N 中英切换 | +| 基线表 | `components/charting/BaselineTable.tsx` | Ant Design 动态列表格 | +| 数据源选择 | `components/charting/DataSourceSelector.tsx` | 项目流水线 / Excel 双通道 | +| Excel 工具 | `utils/chartingExcelUtils.ts` | 模板下载 + 解析 + 图表导出 | +| 主页面 | `pages/SRChartGenerator.tsx` | Tab 切换 PRISMA / 基线表 | + +### 🆕 工具 5 Meta 分析引擎(2026-02-26 开发完成) + +**功能定位:** 定量 Meta 分析引擎,支持 HR / 二分类(OR/RR)/ 连续型数据,生成森林图 + 漏斗图 + 异质性统计量,基于 R Docker 服务(meta 包)。 + +**开发状态:** ✅ 开发完成,E2E 36/36 通过 + +**核心功能:** + +| 功能 | 技术实现 | 状态 | +|------|---------|------| +| 3 种数据类型 | HR(metagen)、二分类(metabin)、连续型(metacont) | ✅ | +| 2 种效应模型 | 随机效应(DerSimonian-Laird)+ 固定效应(Inverse Variance) | ✅ | +| 多效应量度 | HR / OR / RR(二分类)| ✅ | +| 森林图 | R meta::forest() → base64 PNG | ✅ | +| 漏斗图 | R meta::funnel() → base64 PNG | ✅ | +| 异质性统计 | I²、Q 统计量、p 值、Tau² | ✅ | +| 数据输入 | Excel 上传 + 项目流水线(工具 3 提取结果) | ✅ | +| 只读数据预览 | Ant Design Table 预览 + 数据验证 | ✅ | +| Excel 模板 | 3 种数据类型模板 + 示例数据 + 使用说明 | ✅ | +| R 代码可复现 | 返回可复现的 R 代码片段 | ✅ | + +**核心架构决策:** + +| 决策 | 方案 | +|------|------| +| 统计引擎 | R Docker 服务(meta 包),Brain-Hand 架构(Node.js 编排,R 执行) | +| 数据传输 | inline JSON(小数据直传) | +| 效应量回转 | meta 包返回 log 尺度,R 脚本内 exp() 回转为临床可解释值 | +| 前端数据输入 | Excel 上传(编辑后回传)或关联项目流水线自动提取 | +| 数据预览 | 只读 Ant Design Table,不做浏览器内编辑 | + +**新增 API 端点:** +```http +POST /api/v1/asl/meta-analysis/run # 运行 Meta 分析 +GET /api/v1/asl/meta-analysis/project-data/:projectId # 从项目流水线提取数据 +GET /api/v1/asl/meta-analysis/health # R 服务健康检查 +``` + +**新增前端路由:** +``` +/literature/meta-analysis # Meta 分析引擎页面 +``` + +**核心代码组件:** + +| 组件 | 文件 | 说明 | +|------|------|------| +| R 分析脚本 | `r-statistics-service/tools/meta_analysis.R` | metagen/metabin/metacont + exp() 回转 + 森林图/漏斗图 | +| 后端服务 | `meta-analysis/services/MetaAnalysisService.ts` | R Docker 代理 + 项目数据提取 | +| 后端控制器 | `meta-analysis/controllers/MetaAnalysisController.ts` | 3 个 API 端点 | +| 结果面板 | `components/meta/ResultsPanel.tsx` | 汇总统计 + 图表展示 + R 代码 + 下载 | +| Excel 工具 | `utils/metaExcelUtils.ts` | 3 种模板生成 + 解析 + 验证 | +| 主页面 | `pages/MetaAnalysisEngine.tsx` | 3 步向导(数据输入→参数配置→分析结果) | +| E2E 测试 | `meta-analysis/__tests__/meta-e2e-test.ts` | 36 项测试全部通过 | + +**R Docker 更新:** +- Dockerfile 新增 `meta` 包安装 +- 镜像版本维持 `ssa-r-statistics:1.0.1` +- 工具总数从 12 增至 13(新增 `ST_META_ANALYSIS`) +- 详见 `docs/02-通用能力层/06-R统计引擎/01-R统计引擎架构与部署指南.md` + +**关键技术点:** +1. **Log 尺度回转**:meta 包对 HR/OR/RR 返回 log 尺度值,R 脚本中使用 `exp()` 回转为临床可解释值 +2. **R JSON 序列化**:Plumber 默认会将单元素向量包装为数组,前端需要 `Array.isArray` 防御 +3. **数据类型自动检测**:从 Excel 或项目数据自动推断 HR/二分类/连续型 + ### 智能文献检索 DeepSearch V1.x(2026-01-18 MVP完成) **功能概述:** @@ -1503,16 +1621,23 @@ Drawer打开: <50ms 3. ⏳ **outcomes 模板匹配**:根据文献内容自动推荐 survival/continuous/dichotomous 4. ⏳ **缺失字段补充**:country、inclusion_criteria、primary_outcome 等(M3 自定义字段支持) +### 工具 4/5 后续优化 +1. ⏳ **PRISMA 图数据自动关联**:自动读取项目各阶段实际计数,减少手动输入 +2. ⏳ **基线表统计检验**:自动计算 p 值、标准差等描述统计 +3. ⏳ **Meta 分析亚组分析**:支持 subgroup 分层分析 +4. ⏳ **Meta 分析敏感性分析**:逐一剔除法(Leave-one-out) +5. ⏳ **Meta 分析发表偏倚检验**:Egger's test / Begg's test +6. ⏳ **网状 Meta 分析(NMA)**:netmeta 包集成 + ### 短期优化 1. ⏳ Deep Research V2.0 端到端回归测试 2. ⏳ 搜索历史管理(历史任务列表) 3. ⏳ 标题摘要初筛 Prompt 优化(准确率 60% → 85%+) ### 中期(Month 2-3) -1. ⏳ 工具 4(网状 Meta 分析)开发 -2. ⏳ 工具 5(证据质量评价 GRADE)开发 -3. ⏳ 生产环境部署 -4. ⏳ 证据图谱可视化 +1. ⏳ 工具 6(证据质量评价 GRADE)开发 +2. ⏳ 生产环境部署 +3. ⏳ 证据图谱可视化 --- @@ -1522,15 +1647,15 @@ Drawer打开: <50ms --- -**最后更新**:2026-02-25(工具 3 M1+M2 开发完成 + 数据归一化修复) +**最后更新**:2026-02-26(工具 4 SR 图表生成器 + 工具 5 Meta 分析引擎开发完成) **文档状态**:✅ 反映真实状态 -**下次更新时机**:工具 3 M3 动态模板引擎开发完成 +**下次更新时机**:工具 3 M3 动态模板引擎开发完成 或 工具 6 GRADE 开发完成 -**本次更新内容**(v2.2): -- ✅ 工具 3 M1 骨架管线完成:散装派发+Aggregator、PKB ACL、纯文本盲提、3步极简前端 -- ✅ 工具 3 M2 HITL 工作台完成:MinerU 集成、XML Prompt 隔离、fuzzyQuoteMatch、SSE 日志、审核抽屉、Excel 导出 -- ✅ M2 关键修复:DynamicPromptBuilder 扁平输出、ExcelExporter 数据归一化、Schema-driven 前端 -- ✅ 新增工具 3 API 端点(12 个)、前端路由(3 个)、数据库表(4 个) -- ✅ 更新下一步计划:M3 动态模板引擎 + RoB 增强 + 后续工具 4/5 +**本次更新内容**(v2.3): +- ✅ 工具 4 SR 图表生成器完成:PRISMA 2020 流程图(中英切换)+ 基线特征表 + 双通道数据源 + SVG/PNG 导出 +- ✅ 工具 5 Meta 分析引擎完成:HR/二分类/连续型 + 随机/固定效应模型 + 森林图/漏斗图 + R Docker meta 包 + E2E 36/36 通过 +- ✅ 新增工具 4 API 端点(2 个)、工具 5 API 端点(3 个)、前端路由(2 个) +- ✅ R Docker 镜像更新:新增 meta 包,工具总数 13 +- ✅ 更新下一步计划:工具 4/5 后续优化 + 工具 6 GRADE diff --git a/frontend-v2/src/App.tsx b/frontend-v2/src/App.tsx index 939a1d95..6c542a17 100644 --- a/frontend-v2/src/App.tsx +++ b/frontend-v2/src/App.tsx @@ -24,7 +24,10 @@ import UserDetailPage from './modules/admin/pages/UserDetailPage' // 系统知识库管理 import SystemKbListPage from './modules/admin/pages/SystemKbListPage' import SystemKbDetailPage from './modules/admin/pages/SystemKbDetailPage' -// IIT 项目管理 - 已迁移到 modules/iit/ 业务模块 +// IIT 项目管理(运营团队使用) +import IitProjectListPage from './modules/admin/pages/IitProjectListPage' +import IitProjectDetailPage from './modules/admin/pages/IitProjectDetailPage' +import IitQcCockpitPage from './modules/admin/pages/IitQcCockpitPage' // 运营日志 import ActivityLogsPage from './pages/admin/ActivityLogsPage' // 个人中心页面 @@ -118,7 +121,10 @@ function App() { {/* 系统知识库 */} } /> } /> - {/* IIT 项目管理 - 已迁移到 /iit/* 业务模块 */} + {/* IIT 项目管理(运营团队配置) */} + } /> + } /> + } /> {/* 运营日志 */} } /> {/* 系统配置 */} diff --git a/frontend-v2/src/framework/layout/AdminLayout.tsx b/frontend-v2/src/framework/layout/AdminLayout.tsx index 9744e566..58d7002a 100644 --- a/frontend-v2/src/framework/layout/AdminLayout.tsx +++ b/frontend-v2/src/framework/layout/AdminLayout.tsx @@ -14,7 +14,7 @@ import { BellOutlined, BookOutlined, FileTextOutlined, - + ExperimentOutlined, } from '@ant-design/icons' import type { MenuProps } from 'antd' import { useAuth } from '../auth' @@ -96,6 +96,13 @@ const AdminLayout = () => { icon: , label: '租户管理', }, + { type: 'divider' }, + { + key: '/admin/iit-projects', + icon: , + label: 'IIT 项目管理', + }, + { type: 'divider' }, { key: '/admin/activity-logs', icon: , diff --git a/frontend-v2/src/modules/asl/api/index.ts b/frontend-v2/src/modules/asl/api/index.ts index 709f65b1..feeff0ce 100644 --- a/frontend-v2/src/modules/asl/api/index.ts +++ b/frontend-v2/src/modules/asl/api/index.ts @@ -619,6 +619,46 @@ export async function exportExtractionResults( return response.blob(); } +// ==================== 工具 4:SR 图表生成器 API ==================== + +export async function getChartingPrismaData( + projectId: string +): Promise> { + return request(`/charting/prisma-data/${projectId}`); +} + +export async function getChartingBaselineData( + projectId: string +): Promise> { + return request(`/charting/baseline-data/${projectId}`); +} + +// ==================== 工具 5:Meta 分析引擎 API ==================== + +export async function runMetaAnalysis(body: { + data: Record[]; + params: { + data_type: string; + model?: string; + effect_measure?: string; + }; +}): Promise { + return request('/meta-analysis/run', { + method: 'POST', + body: JSON.stringify(body), + }); +} + +export async function getMetaProjectData( + projectId: string +): Promise<{ rows: any[]; detectedType: string | null; count: number }> { + return request(`/meta-analysis/project-data/${projectId}`); +} + +export async function getMetaHealthCheck(): Promise<{ rServiceAvailable: boolean }> { + return request('/meta-analysis/health'); +} + // ==================== 统一导出API对象 ==================== /** @@ -688,4 +728,13 @@ export const aslApi = { getExtractionResultDetail, reviewExtractionResult, exportExtractionResults, + + // 工具 4:SR 图表生成器 + getChartingPrismaData, + getChartingBaselineData, + + // 工具 5:Meta 分析引擎 + runMetaAnalysis, + getMetaProjectData, + getMetaHealthCheck, }; diff --git a/frontend-v2/src/modules/asl/components/ASLLayout.tsx b/frontend-v2/src/modules/asl/components/ASLLayout.tsx index 2ad70270..eef9a0c9 100644 --- a/frontend-v2/src/modules/asl/components/ASLLayout.tsx +++ b/frontend-v2/src/modules/asl/components/ASLLayout.tsx @@ -18,6 +18,7 @@ import { SettingOutlined, CheckSquareOutlined, UnorderedListOutlined, + ApartmentOutlined, } from '@ant-design/icons'; import type { MenuProps } from 'antd'; @@ -112,11 +113,14 @@ const ASLLayout = () => { ], }, { - key: 'data-analysis', + key: '/literature/charting', + icon: , + label: '7. SR 图表生成器', + }, + { + key: '/literature/meta-analysis', icon: , - label: '7. 数据综合分析与报告', - disabled: true, - title: '敬请期待' + label: '8. Meta 分析引擎', }, ]; @@ -136,6 +140,8 @@ const ASLLayout = () => { if (currentPath.includes('screening/title')) return ['title-screening']; if (currentPath.includes('screening/fulltext')) return ['fulltext-screening']; if (currentPath.includes('/extraction')) return ['extraction']; + if (currentPath.includes('/charting')) return []; + if (currentPath.includes('/meta-analysis')) return []; return []; }; const openKeys = getOpenKeys(); diff --git a/frontend-v2/src/modules/asl/components/charting/BaselineTable.tsx b/frontend-v2/src/modules/asl/components/charting/BaselineTable.tsx new file mode 100644 index 00000000..0df30025 --- /dev/null +++ b/frontend-v2/src/modules/asl/components/charting/BaselineTable.tsx @@ -0,0 +1,110 @@ +import React, { useMemo } from 'react'; +import { Table, Typography, Empty } from 'antd'; +import type { BaselineRow } from '../../utils/chartingExcelUtils'; + +const { Title } = Typography; + +interface Props { + data: BaselineRow[]; +} + +const KNOWN_FIELD_LABELS: Record = { + Study_ID: 'Study ID', + study_id: 'Study ID', + Intervention_Name: 'Intervention', + intervention_name: 'Intervention', + Control_Name: 'Control', + control_name: 'Control', + Intervention_N: 'Intervention N', + intervention_n: 'Intervention N', + Control_N: 'Control N', + control_n: 'Control N', + Age_Mean_SD: 'Age (Mean ± SD)', + age_mean_sd: 'Age (Mean ± SD)', + Male_Percent: 'Male %', + male_percent: 'Male %', + study_design: 'Study Design', + Study_Design: 'Study Design', +}; + +function humanize(key: string): string { + if (KNOWN_FIELD_LABELS[key]) return KNOWN_FIELD_LABELS[key]; + return key + .replace(/_/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +const BaselineTable: React.FC = ({ data }) => { + const columns = useMemo(() => { + if (data.length === 0) return []; + + const allKeys = new Set(); + data.forEach((row) => { + Object.keys(row).forEach((k) => allKeys.add(k)); + }); + + const priorityOrder = [ + 'Study_ID', 'study_id', + 'Intervention_Name', 'intervention_name', + 'Control_Name', 'control_name', + 'Intervention_N', 'intervention_n', + 'Control_N', 'control_n', + 'Age_Mean_SD', 'age_mean_sd', + 'Male_Percent', 'male_percent', + ]; + + const orderedKeys: string[] = []; + const seen = new Set(); + + for (const pk of priorityOrder) { + if (allKeys.has(pk) && !seen.has(pk)) { + orderedKeys.push(pk); + seen.add(pk); + } + } + for (const k of allKeys) { + if (!seen.has(k)) { + orderedKeys.push(k); + seen.add(k); + } + } + + return orderedKeys.map((key) => ({ + title: humanize(key), + dataIndex: key, + key, + ellipsis: true, + render: (val: any) => { + if (val === null || val === undefined) return '-'; + if (typeof val === 'object') return JSON.stringify(val); + return String(val); + }, + })); + }, [data]); + + if (data.length === 0) { + return ; + } + + const dataSource = data.map((row, i) => ({ ...row, _rowKey: `row-${i}` })); + + return ( +
+ + Table 1. 纳入研究的基线特征 (Baseline Characteristics of Included Studies) + + + + ); +}; + +export default BaselineTable; diff --git a/frontend-v2/src/modules/asl/components/charting/DataSourceSelector.tsx b/frontend-v2/src/modules/asl/components/charting/DataSourceSelector.tsx new file mode 100644 index 00000000..05bb6d82 --- /dev/null +++ b/frontend-v2/src/modules/asl/components/charting/DataSourceSelector.tsx @@ -0,0 +1,224 @@ +import React, { useState, useCallback } from 'react'; +import { Radio, Select, Upload, Button, Space, Typography, message, Spin } from 'antd'; +import { + LinkOutlined, + CloudUploadOutlined, + DownloadOutlined, + InboxOutlined, + FileExcelOutlined, +} from '@ant-design/icons'; +import { useQuery } from '@tanstack/react-query'; +import { aslApi } from '../../api'; +import { + downloadSRTemplate, + parseSRExcel, + type PrismaData, + type BaselineRow, +} from '../../utils/chartingExcelUtils'; + +const { Text } = Typography; +const { Dragger } = Upload; + +export type InputChannel = 'project' | 'upload'; + +export interface DataSourceResult { + prisma: PrismaData | null; + baseline: BaselineRow[]; +} + +interface Props { + onDataReady: (result: DataSourceResult) => void; + loading?: boolean; +} + +const DataSourceSelector: React.FC = ({ onDataReady, loading }) => { + const [channel, setChannel] = useState('upload'); + const [selectedProjectId, setSelectedProjectId] = useState(null); + const [parsing, setParsing] = useState(false); + const [uploadedFileName, setUploadedFileName] = useState(null); + + const { data: projectsResp, isLoading: loadingProjects } = useQuery({ + queryKey: ['asl-projects'], + queryFn: () => aslApi.listProjects(), + enabled: channel === 'project', + }); + + const projects = projectsResp?.data ?? []; + + const handleFetchFromProject = useCallback(async () => { + if (!selectedProjectId) { + message.warning('请先选择一个项目'); + return; + } + + try { + const [prismaResp, baselineResp] = await Promise.all([ + aslApi.getChartingPrismaData(selectedProjectId), + aslApi.getChartingBaselineData(selectedProjectId), + ]); + + onDataReady({ + prisma: prismaResp.data, + baseline: baselineResp.data.map((r: any) => ({ + Study_ID: r.studyId, + ...r.extractedData, + })), + }); + message.success('流水线数据加载成功'); + } catch (err: any) { + message.error(`加载项目数据失败: ${err.message}`); + } + }, [selectedProjectId, onDataReady]); + + const handleFileUpload = useCallback( + async (file: File) => { + setParsing(true); + try { + const result = await parseSRExcel(file); + setUploadedFileName(file.name); + onDataReady(result); + message.success(`解析 ${file.name} 成功`); + } catch (err: any) { + message.error(err.message); + } finally { + setParsing(false); + } + return false; + }, + [onDataReady], + ); + + return ( +
+
+ 数据来源 +
+ + { + setChannel(e.target.value); + setUploadedFileName(null); + }} + className="w-full" + > + + +
+ + + 关联项目流水线 + +
+ 从筛选和提取工具自动汇总数据 +
+
+
+ + +
+ + + 独立文件上传 + +
+ 无需使用上游工具,上传 Excel 直接出图 +
+
+
+
+
+ + {channel === 'project' && ( +
+ { if (open && projects.length === 0) handleLoadProjects(); }} + loading={loadingProjects} + options={projects.map(p => ({ label: p.name || p.id, value: p.id }))} + /> + + + + )} + + {/* Data Preview */} + {rows.length > 0 && ( + {getDataTypeLabel(dataType)} + } + > +
+ + )} + +
+ +
+ + ); + + const renderStep1 = () => ( +
+ + +
+ 数据类型 +
+ { + const dt = e.target.value; + setConfig(prev => ({ ...prev, dataType: dt })); + setDataType(dt); + }} + > + HR (风险比) + 二分类 + 连续型 + +
+ + + + 效应模型 +
+ setConfig(prev => ({ ...prev, model: e.target.value }))} + > + 随机效应 (Random) + 固定效应 (Fixed) + +
+ + + {config.dataType === 'dichotomous' && ( + + 效应指标 +
+ setInput(e.target.value)} + onPressEnter={() => sendMessage(input)} + placeholder="输入您的问题,例如:最近质控情况怎么样?" + disabled={loading} + size="large" + style={{ borderRadius: 24, paddingLeft: 20 }} + /> +
+ + ); +}; + +export default AiChatPage; diff --git a/r-statistics-service/Dockerfile b/r-statistics-service/Dockerfile index bbb07f46..e61df2a7 100644 --- a/r-statistics-service/Dockerfile +++ b/r-statistics-service/Dockerfile @@ -36,7 +36,8 @@ RUN R -e "install.packages(c( \ 'gridExtra', \ 'gtsummary', \ 'gt', \ - 'broom' \ + 'broom', \ + 'meta' \ ), repos='https://cloud.r-project.org/', Ncpus=2)" # ===== 安全加固:创建非特权用户 ===== diff --git a/r-statistics-service/tools/meta_analysis.R b/r-statistics-service/tools/meta_analysis.R new file mode 100644 index 00000000..854ad402 --- /dev/null +++ b/r-statistics-service/tools/meta_analysis.R @@ -0,0 +1,295 @@ +#' @tool_code ST_META_ANALYSIS +#' @name Meta 分析引擎 +#' @version 1.0.0 +#' @description 支持 HR/二分类/连续型三种数据类型的 Meta 分析,生成森林图和漏斗图 +#' @author ASL Evidence Synthesis Team + +library(glue) +library(ggplot2) +library(base64enc) + +run_analysis <- function(input) { + logs <- c() + log_add <- function(msg) { logs <<- c(logs, paste0("[", Sys.time(), "] ", msg)) } + warnings_list <- c() + + # ===== 依赖检查 ===== + if (!requireNamespace("meta", quietly = TRUE)) { + return(make_error(ERROR_CODES$E101_PACKAGE_MISSING, package = "meta")) + } + library(meta) + + # ===== 数据加载 ===== + log_add("Loading input data") + df <- tryCatch( + load_input_data(input), + error = function(e) { + log_add(paste("Data loading failed:", e$message)) + return(NULL) + } + ) + + if (is.null(df) || nrow(df) < 2) { + return(make_error(ERROR_CODES$E004_SAMPLE_TOO_SMALL, n = ifelse(is.null(df), 0, nrow(df)), min_required = 2)) + } + log_add(glue("Data loaded: {nrow(df)} studies, {ncol(df)} columns")) + + # ===== 参数提取 ===== + p <- input$params + data_type <- tolower(p$data_type %||% "hr") + model_type <- tolower(p$model %||% "random") + effect_measure <- toupper(p$effect_measure %||% "") + + use_random <- model_type == "random" + use_fixed <- model_type == "fixed" + + log_add(glue("Config: data_type={data_type}, model={model_type}")) + + # ===== 执行 Meta 分析 ===== + ma_result <- NULL + + ma_result <- tryCatch( + withCallingHandlers( + { + if (data_type == "hr") { + run_hr_meta(df, use_random, use_fixed, log_add) + } else if (data_type == "dichotomous") { + sm <- if (effect_measure %in% c("OR", "RR", "RD")) effect_measure else "OR" + run_dichotomous_meta(df, sm, use_random, use_fixed, log_add) + } else if (data_type == "continuous") { + run_continuous_meta(df, use_random, use_fixed, log_add) + } else { + return(make_error(ERROR_CODES$E100_INTERNAL_ERROR, details = glue("Unknown data_type: {data_type}"))) + } + }, + warning = function(w) { + warnings_list <<- c(warnings_list, w$message) + invokeRestart("muffleWarning") + } + ), + error = function(e) { + log_add(glue("Meta-analysis failed: {e$message}")) + return(NULL) + } + ) + + if (is.null(ma_result)) { + return(list( + status = "error", + error_code = "E100", + message = "Meta-analysis computation failed. Check data format.", + user_hint = "Please verify your data columns and values.", + trace_log = logs + )) + } + + # ===== 提取结果 ===== + log_add("Extracting results") + + is_random <- use_random + pooled_te <- if (is_random) ma_result$TE.random else ma_result$TE.fixed + pooled_lower <- if (is_random) ma_result$lower.random else ma_result$lower.fixed + pooled_upper <- if (is_random) ma_result$upper.random else ma_result$upper.fixed + pooled_pval <- if (is_random) ma_result$pval.random else ma_result$pval.fixed + + i2_val <- ma_result$I2 + tau2_val <- if (!is.null(ma_result$tau2)) ma_result$tau2 else NA + q_stat <- ma_result$Q + q_pval <- ma_result$pval.Q + k_studies <- ma_result$k + + sm_label <- ma_result$sm + + # Back-transform ratio measures from log scale (HR, OR, RR) + is_ratio <- sm_label %in% c("HR", "OR", "RR") + display_te <- if (is_ratio) exp(pooled_te) else pooled_te + display_lower <- if (is_ratio) exp(pooled_lower) else pooled_lower + display_upper <- if (is_ratio) exp(pooled_upper) else pooled_upper + + log_add(glue("Back-transform: is_ratio={is_ratio}, raw_TE={round(pooled_te,4)}, display={round(display_te,4)}")) + + results_list <- list( + pooled_effect = jsonlite::unbox(round(display_te, 4)), + pooled_lower = jsonlite::unbox(round(display_lower, 4)), + pooled_upper = jsonlite::unbox(round(display_upper, 4)), + pooled_pvalue = jsonlite::unbox(round(pooled_pval, 6)), + i_squared = jsonlite::unbox(round(i2_val * 100, 1)), + tau_squared = jsonlite::unbox(round(tau2_val, 4)), + q_statistic = jsonlite::unbox(round(q_stat, 2)), + q_pvalue = jsonlite::unbox(round(q_pval, 6)), + k_studies = jsonlite::unbox(k_studies), + effect_measure = jsonlite::unbox(sm_label), + model = jsonlite::unbox(ifelse(is_random, "Random Effects", "Fixed Effect")) + ) + + # ===== 生成图表 ===== + log_add("Generating forest plot") + forest_b64 <- generate_forest_plot(ma_result) + + log_add("Generating funnel plot") + funnel_b64 <- generate_funnel_plot(ma_result) + + # ===== 构建 report_blocks ===== + blocks <- list() + + kv_items <- list() + kv_items[["Effect Measure"]] <- sm_label + kv_items[["Model"]] <- ifelse(is_random, "Random Effects (DerSimonian-Laird)", "Fixed Effect (Mantel-Haenszel)") + kv_items[["Studies (k)"]] <- as.character(k_studies) + kv_items[["Pooled Effect"]] <- glue("{round(display_te, 3)} [{round(display_lower, 3)}, {round(display_upper, 3)}]") + kv_items[["P-value"]] <- format_p_value(pooled_pval) + kv_items[["I\u00b2 (heterogeneity)"]] <- glue("{round(i2_val * 100, 1)}%") + kv_items[["Q statistic"]] <- glue("{round(q_stat, 2)} (p = {format_p_value(q_pval)})") + + blocks[[length(blocks) + 1]] <- make_kv_block(kv_items, title = "Meta-Analysis Summary") + + if (!is.null(forest_b64)) { + blocks[[length(blocks) + 1]] <- make_image_block(forest_b64, title = "Forest Plot", alt = "Forest plot of meta-analysis") + } + if (!is.null(funnel_b64)) { + blocks[[length(blocks) + 1]] <- make_image_block(funnel_b64, title = "Funnel Plot", alt = "Funnel plot for publication bias assessment") + } + + heterogeneity_text <- if (i2_val * 100 > 75) { + glue("Substantial heterogeneity observed (I\u00b2 = {round(i2_val*100,1)}%). Consider subgroup analysis or meta-regression.") + } else if (i2_val * 100 > 50) { + glue("Moderate heterogeneity (I\u00b2 = {round(i2_val*100,1)}%). Results should be interpreted with caution.") + } else { + glue("Low heterogeneity (I\u00b2 = {round(i2_val*100,1)}%). Studies appear reasonably homogeneous.") + } + blocks[[length(blocks) + 1]] <- make_markdown_block(heterogeneity_text, title = "Heterogeneity Assessment") + + # ===== 可复现代码 ===== + reproducible_code <- glue(' +# Auto-generated by AI Clinical Research Platform +# Tool: Meta-Analysis Engine (ST_META_ANALYSIS) +# Time: {Sys.time()} +# ================================ + +library(meta) + +# Your data: +# df <- read.csv("meta_data.csv") + +# Run meta-analysis: +# ma <- metagen(TE = log(df$hr), seTE = ..., studlab = df$study_id, sm = "{sm_label}") +# forest(ma) +# funnel(ma) +') + + plots_list <- list() + if (!is.null(forest_b64)) plots_list[[length(plots_list) + 1]] <- forest_b64 + if (!is.null(funnel_b64)) plots_list[[length(plots_list) + 1]] <- funnel_b64 + + log_add("Analysis complete") + + return(list( + status = "success", + message = glue("Meta-analysis completed: {k_studies} studies, model={model_type}"), + warnings = if (length(warnings_list) > 0) warnings_list else NULL, + results = results_list, + report_blocks = blocks, + plots = plots_list, + trace_log = logs, + reproducible_code = as.character(reproducible_code) + )) +} + +# ===== Sub-functions ===== + +`%||%` <- function(a, b) if (is.null(a)) b else a + +format_p_value <- function(p) { + if (is.na(p)) return("NA") + if (p < 0.001) return("p < .001") + return(paste0("p = ", formatC(p, format = "f", digits = 3))) +} + +run_hr_meta <- function(df, use_random, use_fixed, log_add) { + required <- c("hr", "lower_ci", "upper_ci") + check_columns(df, required) + + studlab <- if ("study_id" %in% names(df)) as.character(df$study_id) else paste0("Study ", seq_len(nrow(df))) + + te <- log(df$hr) + se <- (log(df$upper_ci) - log(df$lower_ci)) / (2 * qnorm(0.975)) + + log_add(glue("Running metagen() with {length(te)} studies, sm=HR")) + + metagen( + TE = te, seTE = se, studlab = studlab, + sm = "HR", method.tau = "DL", + random = use_random, fixed = use_fixed + ) +} + +run_dichotomous_meta <- function(df, sm, use_random, use_fixed, log_add) { + required <- c("events_e", "total_e", "events_c", "total_c") + check_columns(df, required) + + studlab <- if ("study_id" %in% names(df)) as.character(df$study_id) else paste0("Study ", seq_len(nrow(df))) + + log_add(glue("Running metabin() with {nrow(df)} studies, sm={sm}")) + + metabin( + event.e = df$events_e, n.e = df$total_e, + event.c = df$events_c, n.c = df$total_c, + studlab = studlab, sm = sm, method.tau = "DL", + random = use_random, fixed = use_fixed + ) +} + +run_continuous_meta <- function(df, use_random, use_fixed, log_add) { + required <- c("mean_e", "sd_e", "n_e", "mean_c", "sd_c", "n_c") + check_columns(df, required) + + studlab <- if ("study_id" %in% names(df)) as.character(df$study_id) else paste0("Study ", seq_len(nrow(df))) + + log_add(glue("Running metacont() with {nrow(df)} studies, sm=MD")) + + metacont( + n.e = df$n_e, mean.e = df$mean_e, sd.e = df$sd_e, + n.c = df$n_c, mean.c = df$mean_c, sd.c = df$sd_c, + studlab = studlab, sm = "MD", method.tau = "DL", + random = use_random, fixed = use_fixed + ) +} + +check_columns <- function(df, required) { + missing <- required[!(required %in% names(df))] + if (length(missing) > 0) { + stop(glue("Missing required columns: {paste(missing, collapse=', ')}")) + } +} + +generate_forest_plot <- function(ma) { + tryCatch({ + plot_h <- max(400, 120 + ma$k * 35) + tmp <- tempfile(fileext = ".png") + png(tmp, width = 900, height = plot_h, res = 100) + meta::forest(ma, sortvar = ma$TE, print.tau2 = TRUE, print.I2 = TRUE, + col.diamond = "steelblue", col.square = "royalblue") + dev.off() + b64 <- base64encode(tmp) + unlink(tmp) + paste0("data:image/png;base64,", b64) + }, error = function(e) { + message(glue("[META] Forest plot error: {e$message}")) + NULL + }) +} + +generate_funnel_plot <- function(ma) { + tryCatch({ + tmp <- tempfile(fileext = ".png") + png(tmp, width = 600, height = 500, res = 100) + meta::funnel(ma, studlab = TRUE, cex.studlab = 0.8) + dev.off() + b64 <- base64encode(tmp) + unlink(tmp) + paste0("data:image/png;base64,", b64) + }, error = function(e) { + message(glue("[META] Funnel plot error: {e$message}")) + NULL + }) +}