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:
2026-02-26 21:51:02 +08:00
parent 7c3cc12b2e
commit 205932bb3f
30 changed files with 3596 additions and 114 deletions

View File

@@ -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 });
}
}

View 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);
}

View 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();

View 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);
});

View File

@@ -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 });
}

View 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);
}

View File

@@ -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();

View File

@@ -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' });
// ==================== 工具 4SR 图表生成器路由 ====================
await fastify.register(chartingRoutes, { prefix: '/charting' });
// ==================== 工具 5Meta 分析引擎路由 ====================
await fastify.register(metaAnalysisRoutes, { prefix: '/meta-analysis' });
}

View File

@@ -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');
}

View File

@@ -1,12 +1,13 @@
# AIclinicalresearch 系统当前状态与开发指南
> **文档版本:** v6.2
> **文档版本:** v6.3
> **创建日期:** 2025-11-28
> **维护者:** 开发团队
> **最后更新:** 2026-02-26
> **🎉 重大里程碑:**
> - **🆕 2026-02-26ASL 工具 4 SR 图表生成器 + 工具 5 Meta 分析引擎开发完成!** PRISMA 流程图(中英切换)+ 基线特征表 + Meta 分析HR/二分类/连续型)+ 森林图/漏斗图 + R Docker meta 包 + E2E 36/36 通过
> - **🆕 2026-02-26CRA Agent V3.0 P0+P1 全部完成!** 自驱动质控流水线 + ChatOrchestrator + LLM Function Calling + E2E 54/54 通过
> - **🆕 2026-02-24ASL 工具 3 V2.0 架构升级至散装派发 + Aggregator** 9 条研发红线 + 散装派发与轮询收口任务模式指南 v1.1 沉淀
> - **2026-02-24ASL 工具 3 V2.0 架构升级至散装派发 + Aggregator** 9 条研发红线 + 散装派发与轮询收口任务模式指南 v1.1 沉淀
> - **2026-02-23ASL 工具 3 V2.0 开发计划完成!** HITL + 动态模板 + M1/M2/M3 三阶段 22 天
> - **🆕 2026-02-23ASL Deep Research V2.0 核心功能完成!** SSE 实时流 + 段落化思考 + 瀑布流 UI + Markdown 渲染 + 引用链接可见 + Word 导出 + 中文数据源
> - **🆕 2026-02-22SSA Phase I-IV 开发完成!** Session 黑板 + 对话层 LLM + 方法咨询 + 对话驱动分析E2E 107/107 通过
@@ -29,17 +30,13 @@
> - **2026-01-24Protocol Agent 框架完成!** 可复用Agent框架+5阶段对话流程
> - **2026-01-22OSS 存储集成完成!** 阿里云 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 增至 13ST_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核心功能全部完成**
| 工具 | 核心功能 | 技术实现 | 状态 |
|------|---------|---------|------|
| 工具 4SR 图表生成器 | PRISMA 2020 流程图(中英切换)| 前端 SVG 渲染 + I18N | ✅ |
| 工具 4SR 图表生成器 | 基线特征表Table 1 | Ant Design 动态列 | ✅ |
| 工具 4SR 图表生成器 | 双通道数据源 | 项目流水线 API + Excel 上传 | ✅ |
| 工具 5Meta 分析引擎 | HR / 二分类 / 连续型 | R meta 包metagen/metabin/metacont | ✅ |
| 工具 5Meta 分析引擎 | 森林图 + 漏斗图 | R base64 PNG | ✅ |
| 工具 5Meta 分析引擎 | 异质性统计量 | I²、Q、p、Tau² | ✅ |
| 工具 5Meta 分析引擎 | 数据输入 | Excel 上传 + 项目流水线 | ✅ |
| 工具 5Meta 分析引擎 | E2E 测试 | 36/36 通过HR/OR/RR/连续型/边界情况) | ✅ |
**关键技术决策**
-**R Docker 集成**meta 包安装到 R Docker 镜像Brain-Hand 架构复用 SSA 基础设施
-**Log 尺度回转**meta 包返回 log 尺度 HR/OR/RRR 脚本内 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.085%核心功能完成、平台基础层100%、RVW95%、通用能力层升级100%)、**PKB95%Dify已替换** 🎉
- 🚧 **开发中****ASL80%,🎉 V2.0 核心功能完成SSE流式+瀑布流UI+HITL+Word导出+中文数据源**、DCTool C 98%Tool B后端100%Tool B前端0%、IIT60%Phase 1.5完成)、**SSAQPER主线100% + Phase I-IV 全部完成E2E 107/107Phase VI 待启动)** 🎉
- 🚧 **开发中****ASL90%,🎉 工具3 M1+M2 + 工具4 + 工具5 完成PRISMA图+Meta分析+R Docker**、DCTool C 98%Tool B后端100%Tool B前端0%、IIT60%Phase 1.5完成)、**SSAQPER主线100% + Phase I-IV 全部完成E2E 107/107Phase 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
---

View File

@@ -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 模块(工具 5Meta 分析引擎)引入,后端代理位于 `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_ANALYSISDockerfile 新增 `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 护栏、热重载说明、常见问题补充 |

View File

@@ -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-23V2.0 核心功能完成SSE 流式架构 + 段落化思考日志 + 引用链接可见化
> - 2026-02-22V2.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导出
-**智能文献检索DeepSearchV1.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 的 extractionResultflattenModuleData 归一化 |
| 图表渲染 | 前端 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 种数据类型 | HRmetagen、二分类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.x2026-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

View File

@@ -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 />} />
{/* 系统配置 */}

View File

@@ -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 />,

View File

@@ -619,6 +619,46 @@ export async function exportExtractionResults(
return response.blob();
}
// ==================== 工具 4SR 图表生成器 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}`);
}
// ==================== 工具 5Meta 分析引擎 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,
// 工具 4SR 图表生成器
getChartingPrismaData,
getChartingBaselineData,
// 工具 5Meta 分析引擎
runMetaAnalysis,
getMetaProjectData,
getMetaHealthCheck,
};

View File

@@ -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();

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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>
);
}

View File

@@ -30,6 +30,12 @@ const ExtractionSetup = lazy(() => import('./pages/ExtractionSetup'));
const ExtractionProgress = lazy(() => import('./pages/ExtractionProgress'));
const ExtractionWorkbench = lazy(() => import('./pages/ExtractionWorkbench'));
// 工具 4SR 图表生成器
const SRChartGenerator = lazy(() => import('./pages/SRChartGenerator'));
// 工具 5Meta 分析引擎
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>
{/* 工具 4SR 图表生成器 */}
<Route path="charting" element={<SRChartGenerator />} />
{/* 工具 5Meta 分析引擎 */}
<Route path="meta-analysis" element={<MetaAnalysisEngine />} />
</Route>
</Routes>
</Suspense>

View 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;

View 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;

View 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;
}

View 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;
}

View File

@@ -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>

View File

@@ -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>
);
};

View 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;

View File

@@ -36,7 +36,8 @@ RUN R -e "install.packages(c( \
'gridExtra', \
'gtsummary', \
'gt', \
'broom' \
'broom', \
'meta' \
), repos='https://cloud.r-project.org/', Ncpus=2)"
# ===== 安全加固:创建非特权用户 =====

View 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
})
}