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
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
11
backend/src/modules/asl/charting/routes/index.ts
Normal file
11
backend/src/modules/asl/charting/routes/index.ts
Normal file
@@ -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);
|
||||
}
|
||||
156
backend/src/modules/asl/charting/services/ChartingService.ts
Normal file
156
backend/src/modules/asl/charting/services/ChartingService.ts
Normal file
@@ -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<string, number>;
|
||||
fullTextAssessed: number;
|
||||
fullTextExcluded: number;
|
||||
fullTextExclusionReasons: Record<string, number>;
|
||||
finalIncluded: number;
|
||||
}
|
||||
|
||||
export interface BaselineRow {
|
||||
studyId: string;
|
||||
snapshotFilename: string;
|
||||
extractedData: Record<string, any>;
|
||||
}
|
||||
|
||||
function flattenModuleData(moduleData: any): Record<string, any> {
|
||||
if (!moduleData) return {};
|
||||
if (Array.isArray(moduleData)) {
|
||||
const flat: Record<string, any> = {};
|
||||
for (const item of moduleData) {
|
||||
if (item && typeof item === 'object' && 'key' in item) {
|
||||
flat[item.key] = item.value ?? '';
|
||||
}
|
||||
}
|
||||
return flat;
|
||||
}
|
||||
const flat: Record<string, any> = {};
|
||||
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<PrismaAggregation> {
|
||||
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<BaselineRow[]> {
|
||||
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<string, any>;
|
||||
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<Record<string, number>> {
|
||||
const excluded = await prisma.aslScreeningResult.findMany({
|
||||
where: { projectId, finalDecision: 'exclude' },
|
||||
select: { exclusionReason: true },
|
||||
});
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
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<Record<string, number>> {
|
||||
const excluded = await prisma.aslFulltextScreeningResult.findMany({
|
||||
where: { projectId, finalDecision: 'exclude' },
|
||||
select: { exclusionReason: true },
|
||||
});
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
for (const r of excluded) {
|
||||
const reason = r.exclusionReason?.trim() || 'Unspecified';
|
||||
counts[reason] = (counts[reason] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
}
|
||||
|
||||
export const chartingService = new ChartingService();
|
||||
299
backend/src/modules/asl/meta-analysis/__tests__/meta-e2e-test.ts
Normal file
299
backend/src/modules/asl/meta-analysis/__tests__/meta-e2e-test.ts
Normal file
@@ -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<any> {
|
||||
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);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
16
backend/src/modules/asl/meta-analysis/routes/index.ts
Normal file
16
backend/src/modules/asl/meta-analysis/routes/index.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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<string, unknown>[];
|
||||
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<string, unknown>[];
|
||||
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<string, unknown>[] = [];
|
||||
let detectedType: MetaDataType | null = null;
|
||||
|
||||
for (const r of results) {
|
||||
const flat = flattenExtractedData(r.extractedData);
|
||||
if (!flat) continue;
|
||||
|
||||
const row: Record<string, unknown> = {
|
||||
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<boolean> {
|
||||
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<string, any> | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
|
||||
const data = raw as Record<string, any>;
|
||||
const flat: Record<string, any> = {};
|
||||
|
||||
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<string, any>)) {
|
||||
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();
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 护栏、热重载说明、常见问题补充 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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() {
|
||||
{/* 系统知识库 */}
|
||||
<Route path="system-kb" element={<SystemKbListPage />} />
|
||||
<Route path="system-kb/:id" element={<SystemKbDetailPage />} />
|
||||
{/* IIT 项目管理 - 已迁移到 /iit/* 业务模块 */}
|
||||
{/* IIT 项目管理(运营团队配置) */}
|
||||
<Route path="iit-projects" element={<IitProjectListPage />} />
|
||||
<Route path="iit-projects/:id" element={<IitProjectDetailPage />} />
|
||||
<Route path="iit-projects/:id/cockpit" element={<IitQcCockpitPage />} />
|
||||
{/* 运营日志 */}
|
||||
<Route path="activity-logs" element={<ActivityLogsPage />} />
|
||||
{/* 系统配置 */}
|
||||
|
||||
@@ -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: <TeamOutlined />,
|
||||
label: '租户管理',
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: '/admin/iit-projects',
|
||||
icon: <ExperimentOutlined />,
|
||||
label: 'IIT 项目管理',
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: '/admin/activity-logs',
|
||||
icon: <FileTextOutlined />,
|
||||
|
||||
@@ -619,6 +619,46 @@ export async function exportExtractionResults(
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
// ==================== 工具 4:SR 图表生成器 API ====================
|
||||
|
||||
export async function getChartingPrismaData(
|
||||
projectId: string
|
||||
): Promise<ApiResponse<any>> {
|
||||
return request(`/charting/prisma-data/${projectId}`);
|
||||
}
|
||||
|
||||
export async function getChartingBaselineData(
|
||||
projectId: string
|
||||
): Promise<ApiResponse<any>> {
|
||||
return request(`/charting/baseline-data/${projectId}`);
|
||||
}
|
||||
|
||||
// ==================== 工具 5:Meta 分析引擎 API ====================
|
||||
|
||||
export async function runMetaAnalysis(body: {
|
||||
data: Record<string, unknown>[];
|
||||
params: {
|
||||
data_type: string;
|
||||
model?: string;
|
||||
effect_measure?: string;
|
||||
};
|
||||
}): Promise<any> {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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: <ApartmentOutlined />,
|
||||
label: '7. SR 图表生成器',
|
||||
},
|
||||
{
|
||||
key: '/literature/meta-analysis',
|
||||
icon: <BarChartOutlined />,
|
||||
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();
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<Props> = ({ data }) => {
|
||||
const columns = useMemo(() => {
|
||||
if (data.length === 0) return [];
|
||||
|
||||
const allKeys = new Set<string>();
|
||||
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<string>();
|
||||
|
||||
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 <Empty description="暂无基线数据" />;
|
||||
}
|
||||
|
||||
const dataSource = data.map((row, i) => ({ ...row, _rowKey: `row-${i}` }));
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Title level={5} className="text-center mb-4">
|
||||
Table 1. 纳入研究的基线特征 (Baseline Characteristics of Included Studies)
|
||||
</Title>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
rowKey="_rowKey"
|
||||
pagination={false}
|
||||
bordered
|
||||
size="small"
|
||||
scroll={{ x: 'max-content' }}
|
||||
className="baseline-academic-table"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BaselineTable;
|
||||
@@ -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<Props> = ({ onDataReady, loading }) => {
|
||||
const [channel, setChannel] = useState<InputChannel>('upload');
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
||||
const [parsing, setParsing] = useState(false);
|
||||
const [uploadedFileName, setUploadedFileName] = useState<string | null>(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 (
|
||||
<div className="space-y-4">
|
||||
<div className="font-bold text-gray-800 border-b pb-2 mb-3">
|
||||
数据来源
|
||||
</div>
|
||||
|
||||
<Radio.Group
|
||||
value={channel}
|
||||
onChange={(e) => {
|
||||
setChannel(e.target.value);
|
||||
setUploadedFileName(null);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<Space direction="vertical" className="w-full">
|
||||
<Radio
|
||||
value="project"
|
||||
className="w-full p-2 rounded border border-transparent hover:bg-gray-50"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
<LinkOutlined className="mr-1" />
|
||||
关联项目流水线
|
||||
</span>
|
||||
<div className="text-xs text-gray-500 mt-0.5 ml-5">
|
||||
从筛选和提取工具自动汇总数据
|
||||
</div>
|
||||
</div>
|
||||
</Radio>
|
||||
|
||||
<Radio
|
||||
value="upload"
|
||||
className="w-full p-2 rounded border border-transparent hover:bg-gray-50"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
<CloudUploadOutlined className="mr-1" />
|
||||
独立文件上传
|
||||
</span>
|
||||
<div className="text-xs text-gray-500 mt-0.5 ml-5">
|
||||
无需使用上游工具,上传 Excel 直接出图
|
||||
</div>
|
||||
</div>
|
||||
</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
|
||||
{channel === 'project' && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 space-y-3">
|
||||
<Select
|
||||
placeholder="请选择项目..."
|
||||
className="w-full"
|
||||
loading={loadingProjects}
|
||||
value={selectedProjectId}
|
||||
onChange={setSelectedProjectId}
|
||||
options={projects.map((p: any) => ({
|
||||
label: p.projectName || p.name || p.id,
|
||||
value: p.id,
|
||||
}))}
|
||||
showSearch
|
||||
filterOption={(input, opt) =>
|
||||
(opt?.label as string)?.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
icon={<LinkOutlined />}
|
||||
onClick={handleFetchFromProject}
|
||||
disabled={!selectedProjectId}
|
||||
loading={loading}
|
||||
>
|
||||
获取流水线数据
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{channel === 'upload' && (
|
||||
<div className="space-y-3">
|
||||
{parsing ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spin tip="正在解析 Excel..." />
|
||||
</div>
|
||||
) : uploadedFileName ? (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-3 flex items-center gap-2">
|
||||
<FileExcelOutlined className="text-green-600 text-lg" />
|
||||
<Text className="text-green-700 font-medium flex-1">
|
||||
{uploadedFileName}
|
||||
</Text>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setUploadedFileName(null)}
|
||||
>
|
||||
替换
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Dragger
|
||||
accept=".xlsx,.xls,.csv"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file) => {
|
||||
handleFileUpload(file as unknown as File);
|
||||
return false;
|
||||
}}
|
||||
className="bg-gray-50"
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text text-sm">
|
||||
将 Excel 文件拖到此处,或点击选择
|
||||
</p>
|
||||
<p className="ant-upload-hint text-xs">
|
||||
支持包含 PRISMA_Data 和 Baseline_Data 工作表的 .xlsx 文件
|
||||
</p>
|
||||
</Dragger>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="link"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={downloadSRTemplate}
|
||||
className="w-full text-left"
|
||||
size="small"
|
||||
>
|
||||
下载 SR 标准模板 (SR_Charting_Template.xlsx)
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataSourceSelector;
|
||||
@@ -0,0 +1,228 @@
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
import type { PrismaData } from '../../utils/chartingExcelUtils';
|
||||
import { exportDiagramAsPng } from '../../utils/chartingExcelUtils';
|
||||
|
||||
type Lang = 'zh' | 'en';
|
||||
|
||||
interface Props {
|
||||
data: PrismaData;
|
||||
lang?: Lang;
|
||||
onLangChange?: (lang: Lang) => void;
|
||||
}
|
||||
|
||||
const I18N = {
|
||||
zh: {
|
||||
phase_id: '识别',
|
||||
phase_screen: '筛选',
|
||||
phase_incl: '纳入',
|
||||
row0_l1: '通过数据库和注册库',
|
||||
row0_l2: '检索到的记录',
|
||||
excl0_title: '筛选前排除的记录:',
|
||||
excl0_body: '重复记录',
|
||||
row1_l1: '经过筛选的记录',
|
||||
excl1_title: '排除的记录',
|
||||
row2_l1: '全文评估',
|
||||
row2_l2: '纳入资格的文章',
|
||||
excl2_title: '全文排除的文章',
|
||||
row3_l1: '纳入系统综述',
|
||||
row3_l2: '的研究',
|
||||
exportSvg: '导出 SVG',
|
||||
exportPng: '导出 PNG',
|
||||
title: 'PRISMA 2020 流程图',
|
||||
},
|
||||
en: {
|
||||
phase_id: 'IDENTIFICATION',
|
||||
phase_screen: 'SCREENING',
|
||||
phase_incl: 'INCLUDED',
|
||||
row0_l1: 'Records identified from',
|
||||
row0_l2: 'databases and registers',
|
||||
excl0_title: 'Records removed before screening:',
|
||||
excl0_body: 'Duplicate records',
|
||||
row1_l1: 'Records screened',
|
||||
excl1_title: 'Records excluded',
|
||||
row2_l1: 'Full-text articles assessed',
|
||||
row2_l2: 'for eligibility',
|
||||
excl2_title: 'Full-text articles excluded',
|
||||
row3_l1: 'Studies included in',
|
||||
row3_l2: 'systematic review',
|
||||
exportSvg: 'Export SVG',
|
||||
exportPng: 'Export PNG',
|
||||
title: 'PRISMA 2020 Flow Diagram',
|
||||
},
|
||||
};
|
||||
|
||||
const fmt = (n: number) => n.toLocaleString();
|
||||
|
||||
const PrismaFlowDiagram: React.FC<Props> = ({ data, lang = 'zh', onLangChange }) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const t = I18N[lang];
|
||||
|
||||
const recordsScreened = data.totalIdentified - data.duplicatesRemoved;
|
||||
const fullTextAssessed = recordsScreened - data.titleAbstractExcluded;
|
||||
|
||||
const parseReasons = (reasons?: string): string[] => {
|
||||
if (!reasons) return [];
|
||||
return reasons.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
};
|
||||
|
||||
const titleReasons = parseReasons(data.titleAbstractExclusionReasons);
|
||||
const ftReasons = parseReasons(data.fullTextExclusionReasons);
|
||||
|
||||
const handleExportSvg = useCallback(() => {
|
||||
if (!svgRef.current) return;
|
||||
const serializer = new XMLSerializer();
|
||||
const svgString = serializer.serializeToString(svgRef.current);
|
||||
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'PRISMA_2020_Flow_Diagram.svg';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, []);
|
||||
|
||||
const handleExportPng = useCallback(() => {
|
||||
if (!svgRef.current) return;
|
||||
exportDiagramAsPng(svgRef.current, 'PRISMA_2020_Flow_Diagram.png');
|
||||
}, []);
|
||||
|
||||
const W = 820;
|
||||
const mainX = 60;
|
||||
const mainW = 260;
|
||||
const exclX = 520;
|
||||
const exclW = 240;
|
||||
const arrowY = (boxY: number, boxH: number) => boxY + boxH / 2;
|
||||
|
||||
const rowH = 90;
|
||||
const gap = 50;
|
||||
const row0Y = 40;
|
||||
const row1Y = row0Y + rowH + gap;
|
||||
const row2Y = row1Y + rowH + gap;
|
||||
const row3Y = row2Y + rowH + gap;
|
||||
const totalH = row3Y + rowH + 40;
|
||||
|
||||
const mainCX = mainX + mainW / 2;
|
||||
const boxH = 70;
|
||||
const exclBoxH = Math.max(60, 40 + ftReasons.length * 16);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
{/* Language toggle */}
|
||||
<div className="inline-flex rounded-md border border-gray-300 overflow-hidden text-sm">
|
||||
<button
|
||||
onClick={() => onLangChange?.('zh')}
|
||||
className={`px-3 py-1 transition-colors ${lang === 'zh' ? 'bg-blue-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'}`}
|
||||
>
|
||||
中文
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onLangChange?.('en')}
|
||||
className={`px-3 py-1 transition-colors border-l border-gray-300 ${lang === 'en' ? 'bg-blue-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'}`}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-5 bg-gray-300" />
|
||||
|
||||
<button
|
||||
onClick={handleExportSvg}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
||||
{t.exportSvg}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportPng}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
||||
{t.exportPng}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={W}
|
||||
height={totalH}
|
||||
viewBox={`0 0 ${W} ${totalH}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", Roboto, sans-serif' }}
|
||||
>
|
||||
<defs>
|
||||
<marker id="arrowDown" markerWidth="8" markerHeight="8" refX="4" refY="4" orient="auto">
|
||||
<path d="M0,0 L8,4 L0,8 Z" fill="#94a3b8" />
|
||||
</marker>
|
||||
<marker id="arrowRight" markerWidth="8" markerHeight="8" refX="8" refY="4" orient="auto">
|
||||
<path d="M0,0 L8,4 L0,8 Z" fill="#94a3b8" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* Title */}
|
||||
<text x={W / 2} y={20} textAnchor="middle" fontSize="15" fontWeight="700" fill="#1f2937">{t.title}</text>
|
||||
|
||||
{/* Phase labels */}
|
||||
<text x="12" y={row0Y + boxH / 2} fontSize="11" fill="#94a3b8" fontWeight="600" transform={`rotate(-90, 12, ${row0Y + boxH / 2})`} textAnchor="middle">{t.phase_id}</text>
|
||||
<text x="12" y={row1Y + boxH / 2 + 20} fontSize="11" fill="#94a3b8" fontWeight="600" transform={`rotate(-90, 12, ${row1Y + boxH / 2 + 20})`} textAnchor="middle">{t.phase_screen}</text>
|
||||
<text x="12" y={row3Y + boxH / 2} fontSize="11" fill="#94a3b8" fontWeight="600" transform={`rotate(-90, 12, ${row3Y + boxH / 2})`} textAnchor="middle">{t.phase_incl}</text>
|
||||
|
||||
{/* Row 0: Records identified */}
|
||||
<rect x={mainX} y={row0Y} width={mainW} height={boxH} rx="6" fill="#ffffff" stroke="#94a3b8" strokeWidth="2" />
|
||||
<text x={mainX + mainW / 2} y={row0Y + 28} textAnchor="middle" fontSize="13" fontWeight="600" fill="#374151">{t.row0_l1}</text>
|
||||
<text x={mainX + mainW / 2} y={row0Y + 46} textAnchor="middle" fontSize="13" fontWeight="600" fill="#374151">{t.row0_l2}</text>
|
||||
<text x={mainX + mainW / 2} y={row0Y + 64} textAnchor="middle" fontSize="14" fontWeight="700" fill="#2563eb">(n = {fmt(data.totalIdentified)})</text>
|
||||
|
||||
<line x1={mainX + mainW} y1={arrowY(row0Y, boxH)} x2={exclX} y2={arrowY(row0Y, boxH)} stroke="#94a3b8" strokeWidth="1.5" markerEnd="url(#arrowRight)" />
|
||||
|
||||
<rect x={exclX} y={row0Y + 5} width={exclW} height={60} rx="5" fill="#fef2f2" stroke="#fca5a5" strokeWidth="1.5" />
|
||||
<text x={exclX + exclW / 2} y={row0Y + 28} textAnchor="middle" fontSize="12" fontWeight="600" fill="#dc2626">{t.excl0_title}</text>
|
||||
<text x={exclX + exclW / 2} y={row0Y + 48} textAnchor="middle" fontSize="12" fill="#6b7280">{t.excl0_body} (n = {fmt(data.duplicatesRemoved)})</text>
|
||||
|
||||
<line x1={mainCX} y1={row0Y + boxH} x2={mainCX} y2={row1Y} stroke="#94a3b8" strokeWidth="1.5" markerEnd="url(#arrowDown)" />
|
||||
|
||||
{/* Row 1: Records screened */}
|
||||
<rect x={mainX} y={row1Y} width={mainW} height={boxH} rx="6" fill="#ffffff" stroke="#94a3b8" strokeWidth="2" />
|
||||
<text x={mainX + mainW / 2} y={row1Y + 30} textAnchor="middle" fontSize="13" fontWeight="600" fill="#374151">{t.row1_l1}</text>
|
||||
<text x={mainX + mainW / 2} y={row1Y + 52} textAnchor="middle" fontSize="14" fontWeight="700" fill="#2563eb">(n = {fmt(recordsScreened)})</text>
|
||||
|
||||
<line x1={mainX + mainW} y1={arrowY(row1Y, boxH)} x2={exclX} y2={arrowY(row1Y, boxH)} stroke="#94a3b8" strokeWidth="1.5" markerEnd="url(#arrowRight)" />
|
||||
|
||||
<rect x={exclX} y={row1Y + 5} width={exclW} height={Math.max(60, 40 + titleReasons.length * 16)} rx="5" fill="#fef2f2" stroke="#fca5a5" strokeWidth="1.5" />
|
||||
<text x={exclX + exclW / 2} y={row1Y + 24} textAnchor="middle" fontSize="12" fontWeight="600" fill="#dc2626">{t.excl1_title}</text>
|
||||
<text x={exclX + exclW / 2} y={row1Y + 42} textAnchor="middle" fontSize="12" fill="#6b7280">(n = {fmt(data.titleAbstractExcluded)})</text>
|
||||
{titleReasons.map((r, i) => (
|
||||
<text key={i} x={exclX + 10} y={row1Y + 58 + i * 16} fontSize="10" fill="#9ca3af">{r}</text>
|
||||
))}
|
||||
|
||||
<line x1={mainCX} y1={row1Y + boxH} x2={mainCX} y2={row2Y} stroke="#94a3b8" strokeWidth="1.5" markerEnd="url(#arrowDown)" />
|
||||
|
||||
{/* Row 2: Full-text assessed */}
|
||||
<rect x={mainX} y={row2Y} width={mainW} height={boxH} rx="6" fill="#ffffff" stroke="#94a3b8" strokeWidth="2" />
|
||||
<text x={mainX + mainW / 2} y={row2Y + 28} textAnchor="middle" fontSize="13" fontWeight="600" fill="#374151">{t.row2_l1}</text>
|
||||
<text x={mainX + mainW / 2} y={row2Y + 46} textAnchor="middle" fontSize="13" fontWeight="600" fill="#374151">{t.row2_l2}</text>
|
||||
<text x={mainX + mainW / 2} y={row2Y + 64} textAnchor="middle" fontSize="14" fontWeight="700" fill="#2563eb">(n = {fmt(fullTextAssessed)})</text>
|
||||
|
||||
<line x1={mainX + mainW} y1={arrowY(row2Y, boxH)} x2={exclX} y2={arrowY(row2Y, boxH)} stroke="#94a3b8" strokeWidth="1.5" markerEnd="url(#arrowRight)" />
|
||||
|
||||
<rect x={exclX} y={row2Y + 5} width={exclW} height={exclBoxH} rx="5" fill="#fef2f2" stroke="#fca5a5" strokeWidth="1.5" />
|
||||
<text x={exclX + exclW / 2} y={row2Y + 24} textAnchor="middle" fontSize="12" fontWeight="600" fill="#dc2626">{t.excl2_title}</text>
|
||||
<text x={exclX + exclW / 2} y={row2Y + 42} textAnchor="middle" fontSize="12" fill="#6b7280">(n = {fmt(data.fullTextExcluded)})</text>
|
||||
{ftReasons.map((r, i) => (
|
||||
<text key={i} x={exclX + 10} y={row2Y + 58 + i * 16} fontSize="10" fill="#9ca3af">{r}</text>
|
||||
))}
|
||||
|
||||
<line x1={mainCX} y1={row2Y + boxH} x2={mainCX} y2={row3Y} stroke="#94a3b8" strokeWidth="1.5" markerEnd="url(#arrowDown)" />
|
||||
|
||||
{/* Row 3: Studies included */}
|
||||
<rect x={mainX} y={row3Y} width={mainW} height={boxH} rx="6" fill="#f0fdf4" stroke="#22c55e" strokeWidth="2.5" />
|
||||
<text x={mainX + mainW / 2} y={row3Y + 28} textAnchor="middle" fontSize="13" fontWeight="600" fill="#166534">{t.row3_l1}</text>
|
||||
<text x={mainX + mainW / 2} y={row3Y + 46} textAnchor="middle" fontSize="13" fontWeight="600" fill="#166534">{t.row3_l2}</text>
|
||||
<text x={mainX + mainW / 2} y={row3Y + 64} textAnchor="middle" fontSize="16" fontWeight="700" fill="#16a34a">(n = {fmt(data.finalIncluded)})</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrismaFlowDiagram;
|
||||
232
frontend-v2/src/modules/asl/components/meta/ResultsPanel.tsx
Normal file
232
frontend-v2/src/modules/asl/components/meta/ResultsPanel.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Meta Analysis Results Panel
|
||||
*
|
||||
* Displays:
|
||||
* - Summary statistics (key-value cards)
|
||||
* - Forest plot (base64 image)
|
||||
* - Funnel plot (base64 image)
|
||||
* - Heterogeneity interpretation
|
||||
*/
|
||||
|
||||
import { Card, Row, Col, Statistic, Tag, Typography, Image, Empty, Divider, Button } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
WarningOutlined,
|
||||
DownloadOutlined,
|
||||
ExperimentOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface MetaResults {
|
||||
pooled_effect: number;
|
||||
pooled_lower: number;
|
||||
pooled_upper: number;
|
||||
pooled_pvalue: number;
|
||||
i_squared: number;
|
||||
tau_squared: number;
|
||||
q_statistic: number;
|
||||
q_pvalue: number;
|
||||
k_studies: number;
|
||||
effect_measure: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface ResultsPanelProps {
|
||||
data: {
|
||||
status: string;
|
||||
results?: MetaResults;
|
||||
report_blocks?: any[];
|
||||
plots?: string[];
|
||||
warnings?: string[];
|
||||
reproducible_code?: string;
|
||||
executionMs?: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function getHeterogeneityLevel(i2: number): { color: string; label: string; icon: React.ReactNode } {
|
||||
if (i2 <= 25) return { color: 'green', label: '低异质性', icon: <CheckCircleOutlined /> };
|
||||
if (i2 <= 50) return { color: 'blue', label: '中等异质性', icon: <ExperimentOutlined /> };
|
||||
if (i2 <= 75) return { color: 'orange', label: '较高异质性', icon: <WarningOutlined /> };
|
||||
return { color: 'red', label: '高异质性', icon: <WarningOutlined /> };
|
||||
}
|
||||
|
||||
function formatPValue(p: number): string {
|
||||
if (p < 0.001) return 'p < .001';
|
||||
return `p = ${p.toFixed(3)}`;
|
||||
}
|
||||
|
||||
function downloadBase64Image(b64: string, filename: string) {
|
||||
const link = document.createElement('a');
|
||||
link.href = b64;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
}
|
||||
|
||||
export default function ResultsPanel({ data }: ResultsPanelProps) {
|
||||
if (!data) {
|
||||
return <Empty description="尚未运行分析" />;
|
||||
}
|
||||
|
||||
if (data.status === 'error') {
|
||||
return (
|
||||
<Card>
|
||||
<Empty
|
||||
description={
|
||||
<span style={{ color: '#ff4d4f' }}>
|
||||
分析失败:{(data as any).message || '未知错误'}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const r = data.results;
|
||||
if (!r) {
|
||||
return <Empty description="无分析结果" />;
|
||||
}
|
||||
|
||||
const hetLevel = getHeterogeneityLevel(r.i_squared);
|
||||
const forestPlot = data.plots?.[0];
|
||||
const funnelPlot = data.plots?.[1];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* Summary Statistics */}
|
||||
<Card title="分析结果摘要" size="small" extra={
|
||||
data.executionMs ? <Text type="secondary">耗时: {(data.executionMs / 1000).toFixed(1)}s</Text> : null
|
||||
}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="合并效应量"
|
||||
value={r.pooled_effect}
|
||||
precision={3}
|
||||
suffix={
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
[{r.pooled_lower.toFixed(3)}, {r.pooled_upper.toFixed(3)}]
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Statistic title="效应指标" value={r.effect_measure} />
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Statistic title="P 值" value={formatPValue(r.pooled_pvalue)} />
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Statistic title="纳入研究数" value={r.k_studies} suffix="个" />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic title="模型" value={r.model} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={6}>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>异质性 (I²)</Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Tag color={hetLevel.color} icon={hetLevel.icon}>
|
||||
{r.i_squared.toFixed(1)}% — {hetLevel.label}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic title="τ²" value={r.tau_squared} precision={4} />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="Cochran's Q"
|
||||
value={r.q_statistic}
|
||||
precision={2}
|
||||
suffix={<Text type="secondary" style={{ fontSize: 12 }}>({formatPValue(r.q_pvalue)})</Text>}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Warnings */}
|
||||
{data.warnings && (
|
||||
<Card size="small" title={<Text type="warning"><WarningOutlined /> 统计警告</Text>}>
|
||||
{(Array.isArray(data.warnings) ? data.warnings : [data.warnings]).map((w: string, i: number) => (
|
||||
<div key={i} style={{ color: '#fa8c16', marginBottom: 4 }}>• {w}</div>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Forest Plot */}
|
||||
{forestPlot && (
|
||||
<Card
|
||||
title="森林图 (Forest Plot)"
|
||||
size="small"
|
||||
extra={
|
||||
<Button
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => downloadBase64Image(forestPlot, 'forest_plot.png')}
|
||||
>
|
||||
下载 PNG
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div style={{ textAlign: 'center', overflow: 'auto' }}>
|
||||
<Image
|
||||
src={forestPlot}
|
||||
alt="Forest Plot"
|
||||
style={{ maxWidth: '100%' }}
|
||||
preview={{ mask: '点击放大' }}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Funnel Plot */}
|
||||
{funnelPlot && (
|
||||
<Card
|
||||
title="漏斗图 (Funnel Plot)"
|
||||
size="small"
|
||||
extra={
|
||||
<Button
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => downloadBase64Image(funnelPlot, 'funnel_plot.png')}
|
||||
>
|
||||
下载 PNG
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div style={{ textAlign: 'center', overflow: 'auto' }}>
|
||||
<Image
|
||||
src={funnelPlot}
|
||||
alt="Funnel Plot"
|
||||
style={{ maxWidth: '100%' }}
|
||||
preview={{ mask: '点击放大' }}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Reproducible Code */}
|
||||
{data.reproducible_code && (
|
||||
<Card title="可复现 R 代码" size="small">
|
||||
<pre style={{
|
||||
background: '#f6f8fa',
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
overflow: 'auto',
|
||||
maxHeight: 200,
|
||||
}}>
|
||||
{data.reproducible_code}
|
||||
</pre>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,12 @@ const ExtractionSetup = lazy(() => import('./pages/ExtractionSetup'));
|
||||
const ExtractionProgress = lazy(() => import('./pages/ExtractionProgress'));
|
||||
const ExtractionWorkbench = lazy(() => import('./pages/ExtractionWorkbench'));
|
||||
|
||||
// 工具 4:SR 图表生成器
|
||||
const SRChartGenerator = lazy(() => import('./pages/SRChartGenerator'));
|
||||
|
||||
// 工具 5:Meta 分析引擎
|
||||
const MetaAnalysisEngine = lazy(() => import('./pages/MetaAnalysisEngine'));
|
||||
|
||||
const ASLModule = () => {
|
||||
return (
|
||||
<Suspense
|
||||
@@ -73,6 +79,12 @@ const ASLModule = () => {
|
||||
<Route path="progress/:taskId" element={<ExtractionProgress />} />
|
||||
<Route path="workbench/:taskId" element={<ExtractionWorkbench />} />
|
||||
</Route>
|
||||
|
||||
{/* 工具 4:SR 图表生成器 */}
|
||||
<Route path="charting" element={<SRChartGenerator />} />
|
||||
|
||||
{/* 工具 5:Meta 分析引擎 */}
|
||||
<Route path="meta-analysis" element={<MetaAnalysisEngine />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
||||
438
frontend-v2/src/modules/asl/pages/MetaAnalysisEngine.tsx
Normal file
438
frontend-v2/src/modules/asl/pages/MetaAnalysisEngine.tsx
Normal file
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Meta Analysis Engine — Tool 5
|
||||
*
|
||||
* Two data channels:
|
||||
* 1. Project Pipeline: fetch approved extraction results
|
||||
* 2. Upload Excel: user uploads template
|
||||
*
|
||||
* Read-only data preview → configure params → run analysis → display results
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Card, Steps, Button, Space, Table, Select, Radio, Upload, message,
|
||||
Typography, Alert, Spin, Tag, Row, Col,
|
||||
} from 'antd';
|
||||
import {
|
||||
UploadOutlined, DownloadOutlined, PlayCircleOutlined,
|
||||
ExperimentOutlined, DatabaseOutlined, FileExcelOutlined,
|
||||
ReloadOutlined, ArrowLeftOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import {
|
||||
downloadMetaTemplate, parseMetaExcel, validateMetaData,
|
||||
getDataTypeLabel, getRequiredColumns,
|
||||
type MetaDataType, type MetaRow,
|
||||
} from '../utils/metaExcelUtils';
|
||||
import { aslApi, listProjects } from '../api';
|
||||
import ResultsPanel from '../components/meta/ResultsPanel';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
type DataChannel = 'project' | 'upload';
|
||||
|
||||
interface AnalysisConfig {
|
||||
dataType: MetaDataType;
|
||||
model: 'random' | 'fixed';
|
||||
effectMeasure: string;
|
||||
}
|
||||
|
||||
const MetaAnalysisEngine = () => {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [channel, setChannel] = useState<DataChannel>('upload');
|
||||
|
||||
// Data state
|
||||
const [rows, setRows] = useState<MetaRow[]>([]);
|
||||
const [dataType, setDataType] = useState<MetaDataType>('hr');
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
// Config state
|
||||
const [config, setConfig] = useState<AnalysisConfig>({
|
||||
dataType: 'hr',
|
||||
model: 'random',
|
||||
effectMeasure: 'OR',
|
||||
});
|
||||
|
||||
// Project channel
|
||||
const [projects, setProjects] = useState<any[]>([]);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>('');
|
||||
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||
const [loadingProjectData, setLoadingProjectData] = useState(false);
|
||||
|
||||
// Analysis state
|
||||
const [running, setRunning] = useState(false);
|
||||
const [results, setResults] = useState<any>(null);
|
||||
|
||||
// ===== Data Loading =====
|
||||
|
||||
const handleLoadProjects = useCallback(async () => {
|
||||
setLoadingProjects(true);
|
||||
try {
|
||||
const res = await listProjects();
|
||||
setProjects(res.data || []);
|
||||
} catch (e: any) {
|
||||
message.error('加载项目失败: ' + e.message);
|
||||
} finally {
|
||||
setLoadingProjects(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleFetchProjectData = useCallback(async () => {
|
||||
if (!selectedProjectId) return;
|
||||
setLoadingProjectData(true);
|
||||
try {
|
||||
const res = await aslApi.getMetaProjectData(selectedProjectId);
|
||||
const { rows: r, detectedType, count } = res;
|
||||
if (count === 0) {
|
||||
message.warning('该项目没有已审核通过的提取结果');
|
||||
return;
|
||||
}
|
||||
setRows(r);
|
||||
if (detectedType) {
|
||||
const dt = detectedType as MetaDataType;
|
||||
setDataType(dt);
|
||||
setConfig(prev => ({ ...prev, dataType: dt }));
|
||||
}
|
||||
message.success(`已加载 ${count} 条研究数据`);
|
||||
} catch (e: any) {
|
||||
message.error('获取项目数据失败: ' + e.message);
|
||||
} finally {
|
||||
setLoadingProjectData(false);
|
||||
}
|
||||
}, [selectedProjectId]);
|
||||
|
||||
const handleFileUpload = useCallback(async (file: File) => {
|
||||
try {
|
||||
const { rows: parsed, detectedType } = await parseMetaExcel(file);
|
||||
if (parsed.length === 0) {
|
||||
message.error('Excel 文件中没有有效数据');
|
||||
return false;
|
||||
}
|
||||
setRows(parsed);
|
||||
if (detectedType) {
|
||||
setDataType(detectedType);
|
||||
setConfig(prev => ({ ...prev, dataType: detectedType }));
|
||||
message.success(`已识别 ${parsed.length} 行 ${getDataTypeLabel(detectedType)} 数据`);
|
||||
} else {
|
||||
message.warning(`已加载 ${parsed.length} 行数据,请手动选择数据类型`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error('Excel 解析失败: ' + e.message);
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
// ===== Validation =====
|
||||
|
||||
const validate = useCallback(() => {
|
||||
const errs = validateMetaData(rows, config.dataType);
|
||||
setValidationErrors(errs);
|
||||
return errs.length === 0;
|
||||
}, [rows, config.dataType]);
|
||||
|
||||
// ===== Run Analysis =====
|
||||
|
||||
const handleRun = useCallback(async () => {
|
||||
if (!validate()) {
|
||||
message.error('数据校验未通过,请检查红色提示');
|
||||
return;
|
||||
}
|
||||
|
||||
setRunning(true);
|
||||
setResults(null);
|
||||
try {
|
||||
const numericRows = rows.map(row => {
|
||||
const nr: Record<string, unknown> = { study_id: row.study_id };
|
||||
const required = getRequiredColumns(config.dataType).filter(k => k !== 'study_id');
|
||||
for (const k of required) {
|
||||
nr[k] = Number(row[k]);
|
||||
}
|
||||
return nr;
|
||||
});
|
||||
|
||||
const res = await aslApi.runMetaAnalysis({
|
||||
data: numericRows,
|
||||
params: {
|
||||
data_type: config.dataType,
|
||||
model: config.model,
|
||||
effect_measure: config.dataType === 'dichotomous' ? config.effectMeasure : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
setResults(res);
|
||||
setCurrentStep(2);
|
||||
message.success('Meta 分析完成');
|
||||
} catch (e: any) {
|
||||
message.error('分析失败: ' + e.message);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}, [rows, config, validate]);
|
||||
|
||||
// ===== Table Columns =====
|
||||
|
||||
const columns = useMemo<ColumnsType<MetaRow>>(() => {
|
||||
if (rows.length === 0) return [];
|
||||
const keys = Object.keys(rows[0]);
|
||||
return keys.map(key => ({
|
||||
title: key,
|
||||
dataIndex: key,
|
||||
key,
|
||||
width: key === 'study_id' ? 160 : 120,
|
||||
render: (val: unknown) => {
|
||||
if (val === undefined || val === null || val === '') {
|
||||
return <Text type="danger">—</Text>;
|
||||
}
|
||||
return String(val);
|
||||
},
|
||||
}));
|
||||
}, [rows]);
|
||||
|
||||
// ===== Render Steps =====
|
||||
|
||||
const renderStep0 = () => (
|
||||
<div style={{ maxWidth: 900, margin: '0 auto' }}>
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Radio.Group
|
||||
value={channel}
|
||||
onChange={e => { setChannel(e.target.value); setRows([]); setValidationErrors([]); }}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
size="large"
|
||||
>
|
||||
<Radio.Button value="upload">
|
||||
<FileExcelOutlined /> 上传 Excel 文件
|
||||
</Radio.Button>
|
||||
<Radio.Button value="project">
|
||||
<DatabaseOutlined /> 关联项目流水线
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Card>
|
||||
|
||||
{channel === 'upload' && (
|
||||
<Card title="上传 Meta 分析数据" size="small">
|
||||
<Paragraph type="secondary">
|
||||
请先下载数据模板,按模板格式填写数据后上传。支持 .xlsx 格式。
|
||||
</Paragraph>
|
||||
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Text>下载模板:</Text>
|
||||
<Button size="small" icon={<DownloadOutlined />} onClick={() => downloadMetaTemplate('hr')}>
|
||||
HR 模板
|
||||
</Button>
|
||||
<Button size="small" icon={<DownloadOutlined />} onClick={() => downloadMetaTemplate('dichotomous')}>
|
||||
二分类模板
|
||||
</Button>
|
||||
<Button size="small" icon={<DownloadOutlined />} onClick={() => downloadMetaTemplate('continuous')}>
|
||||
连续型模板
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Upload.Dragger
|
||||
accept=".xlsx,.xls"
|
||||
maxCount={1}
|
||||
showUploadList={false}
|
||||
beforeUpload={handleFileUpload}
|
||||
>
|
||||
<p className="ant-upload-drag-icon"><UploadOutlined style={{ fontSize: 32, color: '#1890ff' }} /></p>
|
||||
<p className="ant-upload-text">点击或拖拽 Excel 文件到此区域</p>
|
||||
<p className="ant-upload-hint">支持 .xlsx / .xls 格式</p>
|
||||
</Upload.Dragger>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{channel === 'project' && (
|
||||
<Card title="从项目提取结果获取数据" size="small">
|
||||
<Paragraph type="secondary">
|
||||
选择一个项目,自动从已审核通过的全文提取结果中获取 Meta 分析所需数据。
|
||||
</Paragraph>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
style={{ width: 400 }}
|
||||
placeholder="选择筛选项目"
|
||||
value={selectedProjectId || undefined}
|
||||
onChange={setSelectedProjectId}
|
||||
onDropdownVisibleChange={(open) => { if (open && projects.length === 0) handleLoadProjects(); }}
|
||||
loading={loadingProjects}
|
||||
options={projects.map(p => ({ label: p.name || p.id, value: p.id }))}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DatabaseOutlined />}
|
||||
onClick={handleFetchProjectData}
|
||||
loading={loadingProjectData}
|
||||
disabled={!selectedProjectId}
|
||||
>
|
||||
获取数据
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Data Preview */}
|
||||
{rows.length > 0 && (
|
||||
<Card
|
||||
title={`数据预览 (${rows.length} 个研究)`}
|
||||
size="small"
|
||||
style={{ marginTop: 16 }}
|
||||
extra={
|
||||
<Tag color="blue">{getDataTypeLabel(dataType)}</Tag>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
dataSource={rows}
|
||||
columns={columns}
|
||||
rowKey="study_id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content', y: 300 }}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div style={{ textAlign: 'right', marginTop: 16 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={rows.length === 0}
|
||||
onClick={() => setCurrentStep(1)}
|
||||
>
|
||||
下一步:配置分析参数
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderStep1 = () => (
|
||||
<div style={{ maxWidth: 700, margin: '0 auto' }}>
|
||||
<Card title="分析参数配置" size="small">
|
||||
<Row gutter={[16, 24]}>
|
||||
<Col span={24}>
|
||||
<Text strong>数据类型</Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Radio.Group
|
||||
value={config.dataType}
|
||||
onChange={e => {
|
||||
const dt = e.target.value;
|
||||
setConfig(prev => ({ ...prev, dataType: dt }));
|
||||
setDataType(dt);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value="hr">HR (风险比)</Radio.Button>
|
||||
<Radio.Button value="dichotomous">二分类</Radio.Button>
|
||||
<Radio.Button value="continuous">连续型</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Text strong>效应模型</Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Radio.Group
|
||||
value={config.model}
|
||||
onChange={e => setConfig(prev => ({ ...prev, model: e.target.value }))}
|
||||
>
|
||||
<Radio value="random">随机效应 (Random)</Radio>
|
||||
<Radio value="fixed">固定效应 (Fixed)</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
{config.dataType === 'dichotomous' && (
|
||||
<Col span={12}>
|
||||
<Text strong>效应指标</Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Select
|
||||
value={config.effectMeasure}
|
||||
onChange={v => setConfig(prev => ({ ...prev, effectMeasure: v }))}
|
||||
style={{ width: 200 }}
|
||||
options={[
|
||||
{ label: 'OR (比值比)', value: 'OR' },
|
||||
{ label: 'RR (风险比)', value: 'RR' },
|
||||
{ label: 'RD (风险差)', value: 'RD' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert
|
||||
type="error"
|
||||
message="数据校验失败"
|
||||
description={
|
||||
<ul style={{ margin: 0, paddingLeft: 16 }}>
|
||||
{validationErrors.slice(0, 10).map((e, i) => <li key={i}>{e}</li>)}
|
||||
{validationErrors.length > 10 && <li>...还有 {validationErrors.length - 10} 个错误</li>}
|
||||
</ul>
|
||||
}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 16 }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => setCurrentStep(0)}>
|
||||
返回数据输入
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={running}
|
||||
onClick={handleRun}
|
||||
size="large"
|
||||
>
|
||||
运行 Meta 分析
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderStep2 = () => (
|
||||
<div>
|
||||
<ResultsPanel data={results} />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 16 }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => setCurrentStep(1)}>
|
||||
返回参数配置
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { setResults(null); setCurrentStep(0); }}>
|
||||
新建分析
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px 32px' }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={3} style={{ marginBottom: 4 }}>
|
||||
<ExperimentOutlined /> Meta 分析引擎
|
||||
</Title>
|
||||
<Text type="secondary">
|
||||
定量合并多个研究的效应量,生成森林图和漏斗图 · 支持 HR / 二分类 / 连续型数据
|
||||
</Text>
|
||||
<Tag color="green" style={{ marginLeft: 8 }}>支持独立文件模式</Tag>
|
||||
</div>
|
||||
|
||||
<Steps
|
||||
current={currentStep}
|
||||
size="small"
|
||||
style={{ marginBottom: 24, maxWidth: 600 }}
|
||||
items={[
|
||||
{ title: '数据输入' },
|
||||
{ title: '参数配置' },
|
||||
{ title: '分析结果' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Spin spinning={running} tip="正在调用 R 统计引擎,请稍候...">
|
||||
{currentStep === 0 && renderStep0()}
|
||||
{currentStep === 1 && renderStep1()}
|
||||
{currentStep === 2 && renderStep2()}
|
||||
</Spin>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetaAnalysisEngine;
|
||||
146
frontend-v2/src/modules/asl/pages/SRChartGenerator.tsx
Normal file
146
frontend-v2/src/modules/asl/pages/SRChartGenerator.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Radio, Card, Empty, Tag, Space, Typography } from 'antd';
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
TableOutlined,
|
||||
SafetyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import DataSourceSelector, {
|
||||
type DataSourceResult,
|
||||
} from '../components/charting/DataSourceSelector';
|
||||
import PrismaFlowDiagram from '../components/charting/PrismaFlowDiagram';
|
||||
import BaselineTable from '../components/charting/BaselineTable';
|
||||
import type { PrismaData, BaselineRow } from '../utils/chartingExcelUtils';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
type ChartType = 'prisma' | 'baseline' | 'rob';
|
||||
|
||||
const SRChartGenerator: React.FC = () => {
|
||||
const [chartType, setChartType] = useState<ChartType>('prisma');
|
||||
const [prismaData, setPrismaData] = useState<PrismaData | null>(null);
|
||||
const [baselineData, setBaselineData] = useState<BaselineRow[]>([]);
|
||||
const [hasData, setHasData] = useState(false);
|
||||
const [prismaLang, setPrismaLang] = useState<'zh' | 'en'>('zh');
|
||||
|
||||
const handleDataReady = useCallback((result: DataSourceResult) => {
|
||||
if (result.prisma) setPrismaData(result.prisma);
|
||||
if (result.baseline?.length > 0) setBaselineData(result.baseline);
|
||||
setHasData(true);
|
||||
}, []);
|
||||
|
||||
const renderResult = () => {
|
||||
if (!hasData) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 py-20">
|
||||
<ApartmentOutlined style={{ fontSize: 48, color: '#e5e7eb' }} />
|
||||
<Text className="mt-4 text-gray-400">
|
||||
请在左侧选择数据来源并加载数据后生成图表
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (chartType === 'prisma') {
|
||||
if (!prismaData) {
|
||||
return (
|
||||
<Empty description="未找到 PRISMA 数据,请确保 Excel 中包含 'PRISMA_Data' 工作表" />
|
||||
);
|
||||
}
|
||||
return <PrismaFlowDiagram data={prismaData} lang={prismaLang} onLangChange={setPrismaLang} />;
|
||||
}
|
||||
|
||||
if (chartType === 'baseline') {
|
||||
if (baselineData.length === 0) {
|
||||
return (
|
||||
<Empty description="未找到基线数据,请确保 Excel 中包含 'Baseline_Data' 工作表" />
|
||||
);
|
||||
}
|
||||
return <BaselineTable data={baselineData} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 py-20">
|
||||
<SafetyOutlined style={{ fontSize: 48, color: '#e5e7eb' }} />
|
||||
<Text className="mt-4 text-gray-400">
|
||||
偏倚风险汇总图 — 即将推出
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 h-full overflow-auto bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<Title level={4} className="!mb-1">
|
||||
SR 图表生成器
|
||||
</Title>
|
||||
<Text className="text-gray-500 text-sm">
|
||||
生成 PRISMA 流程图、基线特征表等系统综述标准图表,支持一键导出
|
||||
</Text>
|
||||
</div>
|
||||
<Tag color="blue" className="text-xs">
|
||||
支持独立文件模式
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{/* Main layout */}
|
||||
<div className="flex gap-6" style={{ minHeight: 'calc(100vh - 220px)' }}>
|
||||
{/* Left: config panel */}
|
||||
<div className="w-80 shrink-0 space-y-4">
|
||||
<Card size="small" title="图表类型">
|
||||
<Radio.Group
|
||||
value={chartType}
|
||||
onChange={(e) => setChartType(e.target.value)}
|
||||
className="w-full"
|
||||
>
|
||||
<Space direction="vertical" className="w-full">
|
||||
<Radio value="prisma" className="w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<ApartmentOutlined className="text-blue-500" />
|
||||
<span className="font-medium">PRISMA 2020 流程图</span>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="baseline" className="w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<TableOutlined className="text-green-500" />
|
||||
<span className="font-medium">基线特征表 (Table 1)</span>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="rob" className="w-full" disabled>
|
||||
<div className="flex items-center gap-2">
|
||||
<SafetyOutlined className="text-orange-400" />
|
||||
<span className="font-medium text-gray-400">
|
||||
偏倚风险汇总图 (RoB)
|
||||
</span>
|
||||
<Tag color="default" className="text-xs ml-1">即将推出</Tag>
|
||||
</div>
|
||||
</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</Card>
|
||||
|
||||
<Card size="small">
|
||||
<DataSourceSelector onDataReady={handleDataReady} />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right: rendering result */}
|
||||
<Card
|
||||
className="flex-1 min-h-[600px]"
|
||||
title={
|
||||
<span className="text-lg font-bold">
|
||||
渲染预览
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{renderResult()}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SRChartGenerator;
|
||||
220
frontend-v2/src/modules/asl/utils/chartingExcelUtils.ts
Normal file
220
frontend-v2/src/modules/asl/utils/chartingExcelUtils.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface PrismaData {
|
||||
totalIdentified: number;
|
||||
duplicatesRemoved: number;
|
||||
titleAbstractExcluded: number;
|
||||
titleAbstractExclusionReasons?: string;
|
||||
fullTextExcluded: number;
|
||||
fullTextExclusionReasons?: string;
|
||||
finalIncluded: number;
|
||||
}
|
||||
|
||||
export interface BaselineRow {
|
||||
Study_ID: string;
|
||||
Intervention_Name: string;
|
||||
Control_Name: string;
|
||||
Intervention_N: string | number;
|
||||
Control_N: string | number;
|
||||
Age_Mean_SD: string;
|
||||
Male_Percent: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// ==================== Template Download ====================
|
||||
|
||||
export function downloadSRTemplate(): void {
|
||||
const wb = XLSX.utils.book_new();
|
||||
|
||||
const prismaData = [
|
||||
{ Stage: 'Total_Identified', Count: 1245, Exclusion_Reasons: '' },
|
||||
{ Stage: 'Duplicates_Removed', Count: 345, Exclusion_Reasons: '' },
|
||||
{
|
||||
Stage: 'Title_Abstract_Excluded',
|
||||
Count: 700,
|
||||
Exclusion_Reasons: 'Non-RCT: 400, Wrong population: 200, Review articles: 100',
|
||||
},
|
||||
{
|
||||
Stage: 'FullText_Excluded',
|
||||
Count: 80,
|
||||
Exclusion_Reasons: 'Missing outcome data: 50, No PDF available: 30',
|
||||
},
|
||||
{ Stage: 'Final_Included', Count: 120, Exclusion_Reasons: '' },
|
||||
];
|
||||
|
||||
const wsPrisma = XLSX.utils.json_to_sheet(prismaData);
|
||||
wsPrisma['!cols'] = [{ wch: 28 }, { wch: 10 }, { wch: 60 }];
|
||||
XLSX.utils.book_append_sheet(wb, wsPrisma, 'PRISMA_Data');
|
||||
|
||||
const baselineData = [
|
||||
{
|
||||
Study_ID: 'Gandhi 2018',
|
||||
Intervention_Name: 'Pembrolizumab + Chemo',
|
||||
Control_Name: 'Placebo + Chemo',
|
||||
Intervention_N: 410,
|
||||
Control_N: 206,
|
||||
Age_Mean_SD: '62.5 ± 8.1',
|
||||
Male_Percent: '60.5%',
|
||||
},
|
||||
{
|
||||
Study_ID: 'Hellmann 2019',
|
||||
Intervention_Name: 'Nivolumab + Ipilimumab',
|
||||
Control_Name: 'Chemotherapy',
|
||||
Intervention_N: 583,
|
||||
Control_N: 583,
|
||||
Age_Mean_SD: '64.0 ± 9.2',
|
||||
Male_Percent: '68.0%',
|
||||
},
|
||||
];
|
||||
|
||||
const wsBaseline = XLSX.utils.json_to_sheet(baselineData);
|
||||
wsBaseline['!cols'] = [
|
||||
{ wch: 18 }, { wch: 28 }, { wch: 22 },
|
||||
{ wch: 16 }, { wch: 12 }, { wch: 16 }, { wch: 14 },
|
||||
];
|
||||
XLSX.utils.book_append_sheet(wb, wsBaseline, 'Baseline_Data');
|
||||
|
||||
XLSX.writeFile(wb, 'SR_Charting_Template.xlsx');
|
||||
}
|
||||
|
||||
// ==================== Excel Parsing ====================
|
||||
|
||||
export async function parseSRExcel(
|
||||
file: File,
|
||||
): Promise<{ prisma: PrismaData | null; baseline: BaselineRow[] }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const buffer = e.target?.result as ArrayBuffer;
|
||||
const workbook = XLSX.read(buffer, { type: 'array' });
|
||||
|
||||
let prisma: PrismaData | null = null;
|
||||
let baseline: BaselineRow[] = [];
|
||||
|
||||
const prismaSheet = workbook.Sheets['PRISMA_Data'] || workbook.Sheets[workbook.SheetNames[0]];
|
||||
if (prismaSheet) {
|
||||
const rows = XLSX.utils.sheet_to_json<any>(prismaSheet);
|
||||
prisma = parsePrismaRows(rows);
|
||||
}
|
||||
|
||||
const baselineSheet = workbook.Sheets['Baseline_Data'] || workbook.Sheets[workbook.SheetNames[1]];
|
||||
if (baselineSheet) {
|
||||
baseline = XLSX.utils.sheet_to_json<BaselineRow>(baselineSheet);
|
||||
}
|
||||
|
||||
resolve({ prisma, baseline });
|
||||
} catch (error) {
|
||||
reject(new Error(`Excel parsing failed: ${(error as Error).message}`));
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(new Error('File read failed'));
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
function parsePrismaRows(rows: any[]): PrismaData {
|
||||
const data: PrismaData = {
|
||||
totalIdentified: 0,
|
||||
duplicatesRemoved: 0,
|
||||
titleAbstractExcluded: 0,
|
||||
fullTextExcluded: 0,
|
||||
finalIncluded: 0,
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
const stage = String(row.Stage || row.stage || '').trim();
|
||||
const count = Number(row.Count || row.count || 0);
|
||||
const reasons = String(row.Exclusion_Reasons || row.exclusion_reasons || '').trim();
|
||||
|
||||
switch (stage) {
|
||||
case 'Total_Identified':
|
||||
data.totalIdentified = count;
|
||||
break;
|
||||
case 'Duplicates_Removed':
|
||||
data.duplicatesRemoved = count;
|
||||
break;
|
||||
case 'Title_Abstract_Excluded':
|
||||
data.titleAbstractExcluded = count;
|
||||
if (reasons) data.titleAbstractExclusionReasons = reasons;
|
||||
break;
|
||||
case 'FullText_Excluded':
|
||||
data.fullTextExcluded = count;
|
||||
if (reasons) data.fullTextExclusionReasons = reasons;
|
||||
break;
|
||||
case 'Final_Included':
|
||||
data.finalIncluded = count;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// ==================== SVG Export ====================
|
||||
|
||||
export function exportDiagramAsSvg(containerEl: HTMLElement, filename: string): void {
|
||||
const svgEl = containerEl.querySelector('svg');
|
||||
if (!svgEl) {
|
||||
const clone = containerEl.cloneNode(true) as HTMLElement;
|
||||
const blob = new Blob(
|
||||
[`<html><body>${clone.outerHTML}</body></html>`],
|
||||
{ type: 'text/html' },
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename.replace(/\.svg$/, '.html');
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
|
||||
const serializer = new XMLSerializer();
|
||||
const svgString = serializer.serializeToString(svgEl);
|
||||
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function exportDiagramAsPng(svgEl: SVGSVGElement, filename: string): void {
|
||||
const serializer = new XMLSerializer();
|
||||
const svgString = serializer.serializeToString(svgEl);
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const img = new Image();
|
||||
|
||||
const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
|
||||
img.onload = () => {
|
||||
const scale = 2;
|
||||
canvas.width = img.width * scale;
|
||||
canvas.height = img.height * scale;
|
||||
ctx.scale(scale, scale);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, img.width, img.height);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
const pngUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = pngUrl;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(pngUrl);
|
||||
}, 'image/png');
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
}
|
||||
240
frontend-v2/src/modules/asl/utils/metaExcelUtils.ts
Normal file
240
frontend-v2/src/modules/asl/utils/metaExcelUtils.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Meta Analysis Excel Utilities
|
||||
*
|
||||
* Three template types:
|
||||
* - HR (time-to-event): study_id, hr, lower_ci, upper_ci
|
||||
* - Dichotomous: study_id, events_e, total_e, events_c, total_c
|
||||
* - Continuous: study_id, mean_e, sd_e, n_e, mean_c, sd_c, n_c
|
||||
*/
|
||||
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
export type MetaDataType = 'hr' | 'dichotomous' | 'continuous';
|
||||
|
||||
export interface MetaRow {
|
||||
study_id: string;
|
||||
[key: string]: string | number | undefined;
|
||||
}
|
||||
|
||||
const HR_HEADERS = [
|
||||
{ key: 'study_id', label: '研究ID (Study ID)' },
|
||||
{ key: 'hr', label: 'HR (Hazard Ratio)' },
|
||||
{ key: 'lower_ci', label: '95% CI 下限 (Lower)' },
|
||||
{ key: 'upper_ci', label: '95% CI 上限 (Upper)' },
|
||||
];
|
||||
|
||||
const DICHOTOMOUS_HEADERS = [
|
||||
{ key: 'study_id', label: '研究ID (Study ID)' },
|
||||
{ key: 'events_e', label: '实验组事件数 (Events Exp.)' },
|
||||
{ key: 'total_e', label: '实验组总人数 (Total Exp.)' },
|
||||
{ key: 'events_c', label: '对照组事件数 (Events Ctrl.)' },
|
||||
{ key: 'total_c', label: '对照组总人数 (Total Ctrl.)' },
|
||||
];
|
||||
|
||||
const CONTINUOUS_HEADERS = [
|
||||
{ key: 'study_id', label: '研究ID (Study ID)' },
|
||||
{ key: 'mean_e', label: '实验组均值 (Mean Exp.)' },
|
||||
{ key: 'sd_e', label: '实验组标准差 (SD Exp.)' },
|
||||
{ key: 'n_e', label: '实验组人数 (N Exp.)' },
|
||||
{ key: 'mean_c', label: '对照组均值 (Mean Ctrl.)' },
|
||||
{ key: 'sd_c', label: '对照组标准差 (SD Ctrl.)' },
|
||||
{ key: 'n_c', label: '对照组人数 (N Ctrl.)' },
|
||||
];
|
||||
|
||||
const HR_EXAMPLE: Record<string, unknown>[] = [
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
const DICHOTOMOUS_EXAMPLE: Record<string, unknown>[] = [
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
const CONTINUOUS_EXAMPLE: Record<string, unknown>[] = [
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
function getHeadersForType(type: MetaDataType) {
|
||||
switch (type) {
|
||||
case 'hr': return HR_HEADERS;
|
||||
case 'dichotomous': return DICHOTOMOUS_HEADERS;
|
||||
case 'continuous': return CONTINUOUS_HEADERS;
|
||||
}
|
||||
}
|
||||
|
||||
function getExampleForType(type: MetaDataType) {
|
||||
switch (type) {
|
||||
case 'hr': return HR_EXAMPLE;
|
||||
case 'dichotomous': return DICHOTOMOUS_EXAMPLE;
|
||||
case 'continuous': return CONTINUOUS_EXAMPLE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an Excel template for the given data type.
|
||||
* Includes a header row with Chinese+English labels and 3 example rows.
|
||||
*/
|
||||
export function downloadMetaTemplate(type: MetaDataType) {
|
||||
const headers = getHeadersForType(type);
|
||||
const examples = getExampleForType(type);
|
||||
|
||||
const headerLabels = headers.map(h => h.label);
|
||||
const dataRows = examples.map(ex => headers.map(h => ex[h.key] ?? ''));
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet([headerLabels, ...dataRows]);
|
||||
|
||||
const colWidths = headers.map(h => ({ wch: Math.max(h.label.length + 4, 18) }));
|
||||
ws['!cols'] = colWidths;
|
||||
|
||||
const instrWs = XLSX.utils.aoa_to_sheet([
|
||||
['Meta 分析数据模板使用说明'],
|
||||
[],
|
||||
['1. 在 Data 工作表中填写您的数据'],
|
||||
['2. 第一行为列标题,请勿修改列标题'],
|
||||
['3. 从第二行开始填写数据,每行代表一个研究'],
|
||||
['4. study_id 为研究标识,必须唯一'],
|
||||
['5. 数值字段请填写数字,不要包含文字'],
|
||||
[],
|
||||
['数据类型说明:'],
|
||||
type === 'hr' ? ['HR (风险比):填写 HR 值及其 95% 置信区间上下限'] :
|
||||
type === 'dichotomous' ? ['二分类:填写实验组和对照组的事件数及总人数'] :
|
||||
['连续型:填写实验组和对照组的均值、标准差及人数'],
|
||||
]);
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Data');
|
||||
XLSX.utils.book_append_sheet(wb, instrWs, '使用说明');
|
||||
|
||||
const typeLabel = type === 'hr' ? 'HR' : type === 'dichotomous' ? '二分类' : '连续型';
|
||||
XLSX.writeFile(wb, `Meta分析数据模板_${typeLabel}.xlsx`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an uploaded Excel file into MetaRow[] and detect the data type.
|
||||
*/
|
||||
export function parseMetaExcel(file: File): Promise<{
|
||||
rows: MetaRow[];
|
||||
detectedType: MetaDataType | null;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target!.result as ArrayBuffer);
|
||||
const wb = XLSX.read(data, { type: 'array' });
|
||||
|
||||
let sheetName = 'Data';
|
||||
if (!wb.SheetNames.includes('Data')) {
|
||||
sheetName = wb.SheetNames[0];
|
||||
}
|
||||
const ws = wb.Sheets[sheetName];
|
||||
const raw: unknown[][] = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });
|
||||
|
||||
if (raw.length < 2) {
|
||||
return reject(new Error('Excel 文件至少需要包含标题行和 1 行数据'));
|
||||
}
|
||||
|
||||
const headerRow = raw[0] as string[];
|
||||
const keyMap = buildKeyMap(headerRow);
|
||||
|
||||
const rows: MetaRow[] = [];
|
||||
for (let i = 1; i < raw.length; i++) {
|
||||
const cells = raw[i] as (string | number)[];
|
||||
if (cells.every(c => c === '' || c === undefined || c === null)) continue;
|
||||
|
||||
const row: MetaRow = { study_id: '' };
|
||||
for (let j = 0; j < headerRow.length; j++) {
|
||||
const key = keyMap[j];
|
||||
if (key) row[key] = cells[j];
|
||||
}
|
||||
if (!row.study_id) row.study_id = `Study ${i}`;
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
const detectedType = detectDataType(rows);
|
||||
resolve({ rows, detectedType });
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(new Error('文件读取失败'));
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map header labels (Chinese+English) back to canonical keys.
|
||||
*/
|
||||
function buildKeyMap(headerRow: string[]): Record<number, string> {
|
||||
const allHeaders = [...HR_HEADERS, ...DICHOTOMOUS_HEADERS, ...CONTINUOUS_HEADERS];
|
||||
const labelToKey = new Map<string, string>();
|
||||
for (const h of allHeaders) {
|
||||
labelToKey.set(h.label.toLowerCase(), h.key);
|
||||
labelToKey.set(h.key.toLowerCase(), h.key);
|
||||
}
|
||||
|
||||
const map: Record<number, string> = {};
|
||||
for (let i = 0; i < headerRow.length; i++) {
|
||||
const raw = String(headerRow[i]).trim().toLowerCase();
|
||||
const key = labelToKey.get(raw);
|
||||
if (key) {
|
||||
map[i] = key;
|
||||
} else {
|
||||
const sanitized = raw.replace(/[^a-z0-9_]/g, '_');
|
||||
const match = allHeaders.find(h => sanitized.includes(h.key));
|
||||
if (match) map[i] = match.key;
|
||||
else map[i] = sanitized;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function detectDataType(rows: MetaRow[]): MetaDataType | null {
|
||||
if (rows.length === 0) return null;
|
||||
const first = rows[0];
|
||||
if (first.hr !== undefined && first.hr !== '') return 'hr';
|
||||
if (first.events_e !== undefined && first.events_e !== '') return 'dichotomous';
|
||||
if (first.mean_e !== undefined && first.mean_e !== '') return 'continuous';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDataTypeLabel(type: MetaDataType): string {
|
||||
switch (type) {
|
||||
case 'hr': return 'HR (风险比)';
|
||||
case 'dichotomous': return '二分类 (Dichotomous)';
|
||||
case 'continuous': return '连续型 (Continuous)';
|
||||
}
|
||||
}
|
||||
|
||||
export function getRequiredColumns(type: MetaDataType): string[] {
|
||||
return getHeadersForType(type).map(h => h.key);
|
||||
}
|
||||
|
||||
export function validateMetaData(rows: MetaRow[], type: MetaDataType): string[] {
|
||||
const errors: string[] = [];
|
||||
const required = getRequiredColumns(type).filter(k => k !== 'study_id');
|
||||
|
||||
if (rows.length < 2) {
|
||||
errors.push('至少需要 2 个研究才能进行 Meta 分析');
|
||||
}
|
||||
|
||||
rows.forEach((row, i) => {
|
||||
for (const col of required) {
|
||||
const val = row[col];
|
||||
if (val === undefined || val === '' || val === null) {
|
||||
errors.push(`第 ${i + 1} 行缺少 ${col}`);
|
||||
} else if (isNaN(Number(val))) {
|
||||
errors.push(`第 ${i + 1} 行 ${col} 不是有效数字: "${val}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Layout, Menu, Spin, Button, Tag, Tooltip } from 'antd';
|
||||
import { Layout, Menu, Spin, Tag } from 'antd';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
ThunderboltOutlined,
|
||||
FileSearchOutlined,
|
||||
AlertOutlined,
|
||||
SettingOutlined,
|
||||
LinkOutlined,
|
||||
DatabaseOutlined,
|
||||
MessageOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { MenuProps } from 'antd';
|
||||
|
||||
@@ -17,39 +16,39 @@ const { Sider, Content, Header } = Layout;
|
||||
const siderMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
type: 'group',
|
||||
label: '全局与宏观概览',
|
||||
label: '质控总览',
|
||||
children: [
|
||||
{
|
||||
key: 'dashboard',
|
||||
icon: <DashboardOutlined />,
|
||||
label: '项目健康度大盘',
|
||||
},
|
||||
{
|
||||
key: 'variables',
|
||||
icon: <DatabaseOutlined />,
|
||||
label: '变量清单',
|
||||
},
|
||||
{
|
||||
key: 'reports',
|
||||
icon: <FileSearchOutlined />,
|
||||
label: '定期报告与关键事件',
|
||||
label: '报告与关键事件',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
label: 'AI 监查过程与细节',
|
||||
label: 'AI 监查',
|
||||
children: [
|
||||
{
|
||||
key: 'stream',
|
||||
icon: <ThunderboltOutlined />,
|
||||
label: 'AI 实时工作流水',
|
||||
},
|
||||
{
|
||||
key: 'chat',
|
||||
icon: <MessageOutlined />,
|
||||
label: 'AI 对话助手',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
label: '人工介入与协作',
|
||||
label: '人工协作',
|
||||
children: [
|
||||
{
|
||||
key: 'equery',
|
||||
@@ -62,10 +61,10 @@ const siderMenuItems: MenuProps['items'] = [
|
||||
|
||||
const viewTitles: Record<string, string> = {
|
||||
dashboard: '项目健康度大盘',
|
||||
variables: '变量清单',
|
||||
stream: 'AI 实时工作流水',
|
||||
chat: 'AI 对话助手',
|
||||
equery: '待处理电子质疑 (eQuery)',
|
||||
reports: '定期报告与关键事件',
|
||||
reports: '报告与关键事件',
|
||||
};
|
||||
|
||||
const IitLayout: React.FC = () => {
|
||||
@@ -135,30 +134,18 @@ const IitLayout: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 底部:项目设置入口 */}
|
||||
{/* 底部:连接状态 */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderTop: '1px solid #1e293b',
|
||||
marginTop: 'auto',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
}}>
|
||||
<Tooltip title="跳转到项目配置界面(REDCap 连接、质控规则、知识库等)">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => navigate('/iit/config')}
|
||||
style={{ color: '#94a3b8', width: '100%', textAlign: 'left', padding: '4px 12px' }}
|
||||
>
|
||||
项目设置
|
||||
<LinkOutlined style={{ marginLeft: 4, fontSize: 10 }} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<div style={{ fontSize: 11, color: '#475569', marginTop: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ fontSize: 11, color: '#475569', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Tag color="success" style={{ margin: 0, fontSize: 10 }}>EDC 直连</Tag>
|
||||
统一视图,全员可见
|
||||
AI 持续监控中
|
||||
</div>
|
||||
</div>
|
||||
</Sider>
|
||||
|
||||
@@ -6,28 +6,20 @@ const DashboardPage = React.lazy(() => import('./pages/DashboardPage'));
|
||||
const AiStreamPage = React.lazy(() => import('./pages/AiStreamPage'));
|
||||
const EQueryPage = React.lazy(() => import('./pages/EQueryPage'));
|
||||
const ReportsPage = React.lazy(() => import('./pages/ReportsPage'));
|
||||
|
||||
const VariableListPage = React.lazy(() => import('./pages/VariableListPage'));
|
||||
|
||||
const ConfigProjectListPage = React.lazy(() => import('./config/ProjectListPage'));
|
||||
const ConfigProjectDetailPage = React.lazy(() => import('./config/ProjectDetailPage'));
|
||||
const AiChatPage = React.lazy(() => import('./pages/AiChatPage'));
|
||||
|
||||
const IitModule: React.FC = () => {
|
||||
return (
|
||||
<Routes>
|
||||
{/* CRA 质控平台主界面 */}
|
||||
{/* CRA 质控平台 — 终端用户日常使用界面 */}
|
||||
<Route element={<IitLayout />}>
|
||||
<Route index element={<Navigate to="dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="stream" element={<AiStreamPage />} />
|
||||
<Route path="equery" element={<EQueryPage />} />
|
||||
<Route path="reports" element={<ReportsPage />} />
|
||||
<Route path="variables" element={<VariableListPage />} />
|
||||
<Route path="chat" element={<AiChatPage />} />
|
||||
</Route>
|
||||
|
||||
{/* 项目配置界面(独立布局,不使用 CRA 质控平台导航) */}
|
||||
<Route path="config" element={<ConfigProjectListPage />} />
|
||||
<Route path="config/:id" element={<ConfigProjectDetailPage />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
195
frontend-v2/src/modules/iit/pages/AiChatPage.tsx
Normal file
195
frontend-v2/src/modules/iit/pages/AiChatPage.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Input, Button, Spin, Typography, Avatar } from 'antd';
|
||||
import {
|
||||
SendOutlined,
|
||||
RobotOutlined,
|
||||
UserOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
const WELCOME_SUGGESTIONS = [
|
||||
'最新质控报告怎么样?',
|
||||
'有没有严重违规问题?',
|
||||
'通过率趋势如何?',
|
||||
'入排标准是什么?',
|
||||
];
|
||||
|
||||
const AiChatPage: React.FC = () => {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
const sendMessage = async (text: string) => {
|
||||
if (!text.trim() || loading) return;
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: 'user',
|
||||
content: text.trim(),
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/v1/iit/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: text.trim() }),
|
||||
});
|
||||
|
||||
const data = await resp.json();
|
||||
|
||||
const aiMsg: ChatMessage = {
|
||||
id: `ai-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: data.reply || '抱歉,暂时无法回答。',
|
||||
timestamp: new Date(),
|
||||
duration: data.duration,
|
||||
};
|
||||
setMessages(prev => [...prev, aiMsg]);
|
||||
} catch {
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: `err-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: '网络错误,请检查后端服务是否运行。',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', maxWidth: 800, margin: '0 auto' }}>
|
||||
{/* Messages area */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '16px 0' }}>
|
||||
{messages.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', paddingTop: 60 }}>
|
||||
<RobotOutlined style={{ fontSize: 48, color: '#3b82f6', marginBottom: 16 }} />
|
||||
<h2 style={{ color: '#1e293b', marginBottom: 8 }}>CRA AI 对话助手</h2>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 24 }}>
|
||||
基于质控数据和知识库,为您提供项目质量分析和建议
|
||||
</Text>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'center' }}>
|
||||
{WELCOME_SUGGESTIONS.map((s, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
type="dashed"
|
||||
onClick={() => sendMessage(s)}
|
||||
icon={<ThunderboltOutlined />}
|
||||
style={{ borderRadius: 20 }}
|
||||
>
|
||||
{s}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map(msg => (
|
||||
<div
|
||||
key={msg.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
marginBottom: 20,
|
||||
flexDirection: msg.role === 'user' ? 'row-reverse' : 'row',
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
icon={msg.role === 'user' ? <UserOutlined /> : <RobotOutlined />}
|
||||
style={{
|
||||
background: msg.role === 'user' ? '#3b82f6' : '#10b981',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '75%',
|
||||
padding: '12px 16px',
|
||||
borderRadius: msg.role === 'user' ? '16px 16px 4px 16px' : '16px 16px 16px 4px',
|
||||
background: msg.role === 'user' ? '#3b82f6' : '#f1f5f9',
|
||||
color: msg.role === 'user' ? '#fff' : '#1e293b',
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
{msg.role === 'assistant' && msg.duration && (
|
||||
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 6 }}>
|
||||
耗时 {(msg.duration / 1000).toFixed(1)}s
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 20 }}>
|
||||
<Avatar icon={<RobotOutlined />} style={{ background: '#10b981', flexShrink: 0 }} />
|
||||
<div style={{ padding: '12px 16px', background: '#f1f5f9', borderRadius: '16px 16px 16px 4px' }}>
|
||||
<Spin size="small" /> <Text type="secondary" style={{ marginLeft: 8 }}>AI 正在分析...</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div style={{
|
||||
borderTop: '1px solid #e2e8f0',
|
||||
padding: '12px 0',
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
}}>
|
||||
<Input
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onPressEnter={() => sendMessage(input)}
|
||||
placeholder="输入您的问题,例如:最近质控情况怎么样?"
|
||||
disabled={loading}
|
||||
size="large"
|
||||
style={{ borderRadius: 24, paddingLeft: 20 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
onClick={() => sendMessage(input)}
|
||||
disabled={!input.trim() || loading}
|
||||
size="large"
|
||||
shape="circle"
|
||||
style={{ background: '#3b82f6', borderColor: '#3b82f6' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiChatPage;
|
||||
@@ -36,7 +36,8 @@ RUN R -e "install.packages(c( \
|
||||
'gridExtra', \
|
||||
'gtsummary', \
|
||||
'gt', \
|
||||
'broom' \
|
||||
'broom', \
|
||||
'meta' \
|
||||
), repos='https://cloud.r-project.org/', Ncpus=2)"
|
||||
|
||||
# ===== 安全加固:创建非特权用户 =====
|
||||
|
||||
295
r-statistics-service/tools/meta_analysis.R
Normal file
295
r-statistics-service/tools/meta_analysis.R
Normal file
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user