feat(asl): Complete Tool 4 SR Chart Generator and Tool 5 Meta Analysis Engine
Tool 4 - SR Chart Generator: - PRISMA 2020 flow diagram with Chinese/English toggle (SVG) - Baseline characteristics table (Table 1) - Dual data source: project pipeline API + Excel upload - SVG/PNG export support - Backend: ChartingService with Prisma aggregation - Frontend: PrismaFlowDiagram, BaselineTable, DataSourceSelector Tool 5 - Meta Analysis Engine: - 3 data types: HR (metagen), dichotomous (metabin), continuous (metacont) - Random and fixed effects models - Multiple effect measures: HR / OR / RR - Forest plot + funnel plot (base64 PNG from R) - Heterogeneity statistics: I2, Q, p-value, Tau2 - Data input via Excel upload or project pipeline - R Docker image updated with meta package (13 tools total) - E2E test: 36/36 passed - Key fix: exp() back-transformation for log-scale ratio measures Also includes: - IIT CRA Agent V3.0 routing and AI chat page integration - Updated ASL module status guide (v2.3) - Updated system status guide (v6.3) - Updated R statistics engine guide (v1.4) Tested: Frontend renders correctly, backend APIs functional, E2E tests passed Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { chartingService } from '../services/ChartingService.js';
|
||||
import { logger } from '../../../../common/logging/index.js';
|
||||
|
||||
function getUserId(request: FastifyRequest): string {
|
||||
const userId = (request as any).user?.userId;
|
||||
if (!userId) throw new Error('User not authenticated');
|
||||
return userId;
|
||||
}
|
||||
|
||||
export async function getPrismaData(
|
||||
request: FastifyRequest<{ Params: { projectId: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
try {
|
||||
const { projectId } = request.params;
|
||||
getUserId(request);
|
||||
|
||||
const data = await chartingService.getPrismaData(projectId);
|
||||
return reply.send({ success: true, data });
|
||||
} catch (err: any) {
|
||||
logger.error('[Charting] getPrismaData error', { error: err.message });
|
||||
return reply.status(500).send({ success: false, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBaselineData(
|
||||
request: FastifyRequest<{ Params: { projectId: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
try {
|
||||
const { projectId } = request.params;
|
||||
getUserId(request);
|
||||
|
||||
const data = await chartingService.getBaselineData(projectId);
|
||||
return reply.send({ success: true, data });
|
||||
} catch (err: any) {
|
||||
logger.error('[Charting] getBaselineData error', { error: err.message });
|
||||
return reply.status(500).send({ success: false, error: err.message });
|
||||
}
|
||||
}
|
||||
11
backend/src/modules/asl/charting/routes/index.ts
Normal file
11
backend/src/modules/asl/charting/routes/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import * as chartingController from '../controllers/ChartingController.js';
|
||||
import { authenticate, requireModule } from '../../../../common/auth/auth.middleware.js';
|
||||
|
||||
export async function chartingRoutes(fastify: FastifyInstance) {
|
||||
fastify.addHook('onRequest', authenticate);
|
||||
fastify.addHook('onRequest', requireModule('ASL'));
|
||||
|
||||
fastify.get('/prisma-data/:projectId', chartingController.getPrismaData);
|
||||
fastify.get('/baseline-data/:projectId', chartingController.getBaselineData);
|
||||
}
|
||||
156
backend/src/modules/asl/charting/services/ChartingService.ts
Normal file
156
backend/src/modules/asl/charting/services/ChartingService.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { prisma } from '../../../../config/database.js';
|
||||
import { logger } from '../../../../common/logging/index.js';
|
||||
|
||||
export interface PrismaAggregation {
|
||||
totalIdentified: number;
|
||||
duplicatesRemoved: number;
|
||||
recordsScreened: number;
|
||||
titleAbstractExcluded: number;
|
||||
titleAbstractExclusionReasons: Record<string, number>;
|
||||
fullTextAssessed: number;
|
||||
fullTextExcluded: number;
|
||||
fullTextExclusionReasons: Record<string, number>;
|
||||
finalIncluded: number;
|
||||
}
|
||||
|
||||
export interface BaselineRow {
|
||||
studyId: string;
|
||||
snapshotFilename: string;
|
||||
extractedData: Record<string, any>;
|
||||
}
|
||||
|
||||
function flattenModuleData(moduleData: any): Record<string, any> {
|
||||
if (!moduleData) return {};
|
||||
if (Array.isArray(moduleData)) {
|
||||
const flat: Record<string, any> = {};
|
||||
for (const item of moduleData) {
|
||||
if (item && typeof item === 'object' && 'key' in item) {
|
||||
flat[item.key] = item.value ?? '';
|
||||
}
|
||||
}
|
||||
return flat;
|
||||
}
|
||||
const flat: Record<string, any> = {};
|
||||
for (const [k, v] of Object.entries(moduleData)) {
|
||||
if (v && typeof v === 'object' && 'value' in (v as any)) {
|
||||
flat[k] = (v as any).value ?? '';
|
||||
} else {
|
||||
flat[k] = v;
|
||||
}
|
||||
}
|
||||
return flat;
|
||||
}
|
||||
|
||||
export class ChartingService {
|
||||
|
||||
async getPrismaData(projectId: string): Promise<PrismaAggregation> {
|
||||
logger.info(`[Charting] Aggregating PRISMA data for project ${projectId}`);
|
||||
|
||||
const [
|
||||
totalLiteratures,
|
||||
screeningExcluded,
|
||||
screeningIncluded,
|
||||
fulltextExcluded,
|
||||
fulltextIncluded,
|
||||
approvedResults,
|
||||
] = await Promise.all([
|
||||
prisma.aslLiterature.count({ where: { projectId } }),
|
||||
|
||||
prisma.aslScreeningResult.count({
|
||||
where: { projectId, finalDecision: 'exclude' },
|
||||
}),
|
||||
prisma.aslScreeningResult.count({
|
||||
where: { projectId, finalDecision: 'include' },
|
||||
}),
|
||||
|
||||
prisma.aslFulltextScreeningResult.count({
|
||||
where: { projectId, finalDecision: 'exclude' },
|
||||
}),
|
||||
prisma.aslFulltextScreeningResult.count({
|
||||
where: { projectId, finalDecision: 'include' },
|
||||
}),
|
||||
|
||||
prisma.aslExtractionResult.count({
|
||||
where: { projectId, reviewStatus: 'approved' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const fulltextExclusionReasons = await this.aggregateFulltextExclusionReasons(projectId);
|
||||
const titleExclusionReasons = await this.aggregateTitleExclusionReasons(projectId);
|
||||
|
||||
const duplicatesRemoved = 0;
|
||||
const recordsScreened = totalLiteratures - duplicatesRemoved;
|
||||
const fullTextAssessed = recordsScreened - screeningExcluded;
|
||||
|
||||
return {
|
||||
totalIdentified: totalLiteratures,
|
||||
duplicatesRemoved,
|
||||
recordsScreened,
|
||||
titleAbstractExcluded: screeningExcluded,
|
||||
titleAbstractExclusionReasons: titleExclusionReasons,
|
||||
fullTextAssessed,
|
||||
fullTextExcluded: fulltextExcluded,
|
||||
fullTextExclusionReasons: fulltextExclusionReasons,
|
||||
finalIncluded: approvedResults,
|
||||
};
|
||||
}
|
||||
|
||||
async getBaselineData(projectId: string): Promise<BaselineRow[]> {
|
||||
logger.info(`[Charting] Fetching baseline data for project ${projectId}`);
|
||||
|
||||
const results = await prisma.aslExtractionResult.findMany({
|
||||
where: { projectId, reviewStatus: 'approved', status: 'completed' },
|
||||
select: {
|
||||
snapshotFilename: true,
|
||||
extractedData: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
return results.map((r) => {
|
||||
const data = (r.extractedData ?? {}) as Record<string, any>;
|
||||
const metaFlat = flattenModuleData(data.metadata);
|
||||
const baselineFlat = flattenModuleData(data.baseline);
|
||||
|
||||
return {
|
||||
studyId: metaFlat.study_id || r.snapshotFilename || 'Unknown',
|
||||
snapshotFilename: r.snapshotFilename,
|
||||
extractedData: { ...metaFlat, ...baselineFlat },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async aggregateTitleExclusionReasons(
|
||||
projectId: string,
|
||||
): Promise<Record<string, number>> {
|
||||
const excluded = await prisma.aslScreeningResult.findMany({
|
||||
where: { projectId, finalDecision: 'exclude' },
|
||||
select: { exclusionReason: true },
|
||||
});
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
for (const r of excluded) {
|
||||
const reason = r.exclusionReason?.trim() || 'Unspecified';
|
||||
counts[reason] = (counts[reason] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
private async aggregateFulltextExclusionReasons(
|
||||
projectId: string,
|
||||
): Promise<Record<string, number>> {
|
||||
const excluded = await prisma.aslFulltextScreeningResult.findMany({
|
||||
where: { projectId, finalDecision: 'exclude' },
|
||||
select: { exclusionReason: true },
|
||||
});
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
for (const r of excluded) {
|
||||
const reason = r.exclusionReason?.trim() || 'Unspecified';
|
||||
counts[reason] = (counts[reason] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
}
|
||||
|
||||
export const chartingService = new ChartingService();
|
||||
299
backend/src/modules/asl/meta-analysis/__tests__/meta-e2e-test.ts
Normal file
299
backend/src/modules/asl/meta-analysis/__tests__/meta-e2e-test.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Meta Analysis Engine — End-to-End Test
|
||||
*
|
||||
* Tests the full pipeline: Node.js Backend → R Docker → Results
|
||||
* Covers all 3 data types: HR, Dichotomous, Continuous
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx src/modules/asl/meta-analysis/__tests__/meta-e2e-test.ts
|
||||
*/
|
||||
|
||||
const R_SERVICE_URL = process.env.R_SERVICE_URL || 'http://localhost:8082';
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
const R_TOOL_CODE = 'ST_META_ANALYSIS';
|
||||
|
||||
// ==================== Test Data ====================
|
||||
|
||||
const HR_DATA = [
|
||||
{ study_id: 'Gandhi 2018', hr: 0.49, lower_ci: 0.38, upper_ci: 0.64 },
|
||||
{ study_id: 'Socinski 2018', hr: 0.56, lower_ci: 0.45, upper_ci: 0.70 },
|
||||
{ study_id: 'West 2019', hr: 0.60, lower_ci: 0.45, upper_ci: 0.80 },
|
||||
{ study_id: 'Paz-Ares 2018', hr: 0.64, lower_ci: 0.49, upper_ci: 0.85 },
|
||||
{ study_id: 'Jotte 2020', hr: 0.71, lower_ci: 0.56, upper_ci: 0.91 },
|
||||
];
|
||||
|
||||
const DICHOTOMOUS_DATA = [
|
||||
{ study_id: 'Smith 2020', events_e: 25, total_e: 100, events_c: 40, total_c: 100 },
|
||||
{ study_id: 'Jones 2021', events_e: 30, total_e: 150, events_c: 55, total_c: 150 },
|
||||
{ study_id: 'Wang 2022', events_e: 12, total_e: 80, events_c: 22, total_c: 80 },
|
||||
{ study_id: 'Lee 2021', events_e: 18, total_e: 120, events_c: 35, total_c: 120 },
|
||||
];
|
||||
|
||||
const CONTINUOUS_DATA = [
|
||||
{ study_id: 'Trial A', mean_e: 5.2, sd_e: 1.3, n_e: 50, mean_c: 6.8, sd_c: 1.5, n_c: 50 },
|
||||
{ study_id: 'Trial B', mean_e: 4.8, sd_e: 1.1, n_e: 60, mean_c: 6.5, sd_c: 1.4, n_c: 60 },
|
||||
{ study_id: 'Trial C', mean_e: 5.0, sd_e: 1.2, n_e: 45, mean_c: 7.0, sd_c: 1.6, n_c: 45 },
|
||||
{ study_id: 'Trial D', mean_e: 5.5, sd_e: 1.4, n_e: 55, mean_c: 6.2, sd_c: 1.3, n_c: 55 },
|
||||
{ study_id: 'Trial E', mean_e: 4.5, sd_e: 1.0, n_e: 40, mean_c: 6.0, sd_c: 1.2, n_c: 40 },
|
||||
];
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition: boolean, msg: string) {
|
||||
if (condition) {
|
||||
console.log(` ✅ ${msg}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(` ❌ FAIL: ${msg}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
async function postJson(url: string, body: unknown): Promise<any> {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ==================== Tests ====================
|
||||
|
||||
async function testRHealth() {
|
||||
console.log('\n🔍 Test 0: R Service Health Check');
|
||||
try {
|
||||
const res = await fetch(`${R_SERVICE_URL}/health`);
|
||||
const data = await res.json();
|
||||
const status = Array.isArray(data.status) ? data.status[0] : data.status;
|
||||
assert(status === 'ok', `R service status: ${status}`);
|
||||
const toolCount = Array.isArray(data.tools_loaded) ? data.tools_loaded[0] : data.tools_loaded;
|
||||
assert(typeof toolCount === 'number' && toolCount > 0, `Tools loaded: ${toolCount}`);
|
||||
console.log(` R service version: ${data.version}, dev_mode: ${data.dev_mode}`);
|
||||
} catch (e: any) {
|
||||
assert(false, `R service unreachable: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function testRToolList() {
|
||||
console.log('\n🔍 Test 0.5: Verify meta_analysis tool registered');
|
||||
try {
|
||||
const res = await fetch(`${R_SERVICE_URL}/api/v1/tools`);
|
||||
const data = await res.json();
|
||||
const tools: string[] = data.tools || [];
|
||||
assert(tools.includes('meta_analysis'), `meta_analysis in tool list: [${tools.join(', ')}]`);
|
||||
} catch (e: any) {
|
||||
assert(false, `Tool list fetch failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function testHRMeta() {
|
||||
console.log('\n🔍 Test 1: HR (Hazard Ratio) Meta-Analysis — Direct R call');
|
||||
const body = {
|
||||
data_source: { type: 'inline', data: HR_DATA },
|
||||
params: { data_type: 'hr', model: 'random' },
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await postJson(`${R_SERVICE_URL}/api/v1/skills/${R_TOOL_CODE}`, body);
|
||||
|
||||
assert(result.status === 'success', `Status: ${result.status}`);
|
||||
assert(result.results != null, 'Results object exists');
|
||||
|
||||
if (result.results) {
|
||||
const r = result.results;
|
||||
assert(r.k_studies === 5, `k_studies = ${r.k_studies} (expected 5)`);
|
||||
assert(r.effect_measure === 'HR', `effect_measure = ${r.effect_measure}`);
|
||||
assert(r.pooled_effect > 0 && r.pooled_effect < 1, `Pooled HR = ${r.pooled_effect} (0 < HR < 1, favors treatment)`);
|
||||
assert(r.pooled_lower > 0 && r.pooled_lower < 1, `Lower CI = ${r.pooled_lower} (> 0)`);
|
||||
assert(r.pooled_upper > 0 && r.pooled_upper < 1, `Upper CI = ${r.pooled_upper} (< 1 = significant)`);
|
||||
assert(typeof r.i_squared === 'number', `I² = ${r.i_squared}%`);
|
||||
assert(typeof r.tau_squared === 'number', `τ² = ${r.tau_squared}`);
|
||||
assert(r.model === 'Random Effects', `Model: ${r.model}`);
|
||||
console.log(` Summary: HR = ${r.pooled_effect} [${r.pooled_lower}, ${r.pooled_upper}], I² = ${r.i_squared}%`);
|
||||
}
|
||||
|
||||
assert(Array.isArray(result.plots) && result.plots.length >= 2, `Plots count: ${result.plots?.length}`);
|
||||
if (result.plots?.[0]) {
|
||||
assert(result.plots[0].startsWith('data:image/png;base64,'), 'Forest plot is base64 PNG');
|
||||
console.log(` Forest plot size: ${Math.round(result.plots[0].length / 1024)} KB`);
|
||||
}
|
||||
if (result.plots?.[1]) {
|
||||
assert(result.plots[1].startsWith('data:image/png;base64,'), 'Funnel plot is base64 PNG');
|
||||
console.log(` Funnel plot size: ${Math.round(result.plots[1].length / 1024)} KB`);
|
||||
}
|
||||
|
||||
assert(Array.isArray(result.report_blocks), `Report blocks: ${result.report_blocks?.length}`);
|
||||
assert(Array.isArray(result.trace_log), `Trace log entries: ${result.trace_log?.length}`);
|
||||
} catch (e: any) {
|
||||
assert(false, `HR meta-analysis failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function testDichotomousMeta() {
|
||||
console.log('\n🔍 Test 2: Dichotomous (OR) Meta-Analysis — Direct R call');
|
||||
const body = {
|
||||
data_source: { type: 'inline', data: DICHOTOMOUS_DATA },
|
||||
params: { data_type: 'dichotomous', model: 'random', effect_measure: 'OR' },
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await postJson(`${R_SERVICE_URL}/api/v1/skills/${R_TOOL_CODE}`, body);
|
||||
|
||||
assert(result.status === 'success', `Status: ${result.status}`);
|
||||
|
||||
if (result.results) {
|
||||
const r = result.results;
|
||||
assert(r.k_studies === 4, `k_studies = ${r.k_studies} (expected 4)`);
|
||||
assert(r.effect_measure === 'OR', `effect_measure = ${r.effect_measure}`);
|
||||
assert(r.pooled_effect > 0 && r.pooled_effect < 1, `Pooled OR = ${r.pooled_effect} (0 < OR < 1, favors treatment)`);
|
||||
assert(typeof r.pooled_pvalue === 'number', `P-value = ${r.pooled_pvalue}`);
|
||||
console.log(` Summary: OR = ${r.pooled_effect} [${r.pooled_lower}, ${r.pooled_upper}], p = ${r.pooled_pvalue}`);
|
||||
}
|
||||
|
||||
assert(Array.isArray(result.plots) && result.plots.length >= 2, 'Has forest + funnel plots');
|
||||
} catch (e: any) {
|
||||
assert(false, `Dichotomous meta-analysis failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function testContinuousMeta() {
|
||||
console.log('\n🔍 Test 3: Continuous (MD) Meta-Analysis — Direct R call');
|
||||
const body = {
|
||||
data_source: { type: 'inline', data: CONTINUOUS_DATA },
|
||||
params: { data_type: 'continuous', model: 'random' },
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await postJson(`${R_SERVICE_URL}/api/v1/skills/${R_TOOL_CODE}`, body);
|
||||
|
||||
assert(result.status === 'success', `Status: ${result.status}`);
|
||||
|
||||
if (result.results) {
|
||||
const r = result.results;
|
||||
assert(r.k_studies === 5, `k_studies = ${r.k_studies} (expected 5)`);
|
||||
assert(r.effect_measure === 'MD', `effect_measure = ${r.effect_measure}`);
|
||||
assert(r.pooled_effect < 0, `Pooled MD = ${r.pooled_effect} (< 0 = treatment lower)`);
|
||||
assert(typeof r.i_squared === 'number', `I² = ${r.i_squared}%`);
|
||||
console.log(` Summary: MD = ${r.pooled_effect} [${r.pooled_lower}, ${r.pooled_upper}], I² = ${r.i_squared}%`);
|
||||
}
|
||||
|
||||
assert(Array.isArray(result.plots) && result.plots.length >= 2, 'Has forest + funnel plots');
|
||||
} catch (e: any) {
|
||||
assert(false, `Continuous meta-analysis failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function testFixedEffectModel() {
|
||||
console.log('\n🔍 Test 4: Fixed Effect Model');
|
||||
const body = {
|
||||
data_source: { type: 'inline', data: HR_DATA },
|
||||
params: { data_type: 'hr', model: 'fixed' },
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await postJson(`${R_SERVICE_URL}/api/v1/skills/${R_TOOL_CODE}`, body);
|
||||
assert(result.status === 'success', `Status: ${result.status}`);
|
||||
if (result.results) {
|
||||
assert(result.results.model === 'Fixed Effect', `Model: ${result.results.model}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
assert(false, `Fixed effect test failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function testInsufficientData() {
|
||||
console.log('\n🔍 Test 5: Edge case — Only 1 study (should error)');
|
||||
const body = {
|
||||
data_source: { type: 'inline', data: [HR_DATA[0]] },
|
||||
params: { data_type: 'hr', model: 'random' },
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await postJson(`${R_SERVICE_URL}/api/v1/skills/${R_TOOL_CODE}`, body);
|
||||
assert(result.status === 'error', `Status should be error: ${result.status}`);
|
||||
console.log(` Error message: ${result.message || result.error_code}`);
|
||||
} catch (e: any) {
|
||||
assert(false, `Insufficient data test failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function testBackendProxy() {
|
||||
console.log('\n🔍 Test 6: Backend API Proxy (/api/v1/asl/meta-analysis/run)');
|
||||
console.log(' ⚠️ Requires backend running on port 3000 with valid auth token');
|
||||
|
||||
try {
|
||||
const healthRes = await fetch(`${BACKEND_URL}/api/v1/asl/meta-analysis/health`, {
|
||||
headers: { 'Authorization': 'Bearer test' },
|
||||
});
|
||||
if (healthRes.status === 401) {
|
||||
console.log(' ⏭️ Skipped: Backend requires authentication (expected in dev)');
|
||||
return;
|
||||
}
|
||||
const healthData = await healthRes.json();
|
||||
assert(healthData.rServiceAvailable === true, `R service reachable from backend: ${healthData.rServiceAvailable}`);
|
||||
} catch (e: any) {
|
||||
console.log(` ⏭️ Skipped: Backend not running (${e.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function testDichotomousRR() {
|
||||
console.log('\n🔍 Test 7: Dichotomous with RR effect measure');
|
||||
const body = {
|
||||
data_source: { type: 'inline', data: DICHOTOMOUS_DATA },
|
||||
params: { data_type: 'dichotomous', model: 'random', effect_measure: 'RR' },
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await postJson(`${R_SERVICE_URL}/api/v1/skills/${R_TOOL_CODE}`, body);
|
||||
assert(result.status === 'success', `Status: ${result.status}`);
|
||||
if (result.results) {
|
||||
assert(result.results.effect_measure === 'RR', `effect_measure = ${result.results.effect_measure}`);
|
||||
assert(result.results.pooled_effect > 0 && result.results.pooled_effect < 1, `Pooled RR = ${result.results.pooled_effect} (0 < RR < 1)`);
|
||||
console.log(` Summary: RR = ${result.results.pooled_effect} [${result.results.pooled_lower}, ${result.results.pooled_upper}]`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
assert(false, `Dichotomous RR test failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Runner ====================
|
||||
|
||||
async function main() {
|
||||
console.log('═══════════════════════════════════════════════════════');
|
||||
console.log(' Meta Analysis Engine — End-to-End Test Suite');
|
||||
console.log('═══════════════════════════════════════════════════════');
|
||||
console.log(` R Service: ${R_SERVICE_URL}`);
|
||||
console.log(` Backend: ${BACKEND_URL}`);
|
||||
console.log(` Time: ${new Date().toISOString()}`);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
await testRHealth();
|
||||
await testRToolList();
|
||||
await testHRMeta();
|
||||
await testDichotomousMeta();
|
||||
await testContinuousMeta();
|
||||
await testFixedEffectModel();
|
||||
await testInsufficientData();
|
||||
await testDichotomousRR();
|
||||
await testBackendProxy();
|
||||
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
|
||||
console.log('\n═══════════════════════════════════════════════════════');
|
||||
console.log(` Results: ${passed} passed, ${failed} failed (${elapsed}s)`);
|
||||
console.log('═══════════════════════════════════════════════════════');
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Meta Analysis Controller
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { metaAnalysisService } from '../services/MetaAnalysisService.js';
|
||||
import type { MetaAnalysisRequest } from '../services/MetaAnalysisService.js';
|
||||
|
||||
export async function runAnalysis(
|
||||
request: FastifyRequest<{ Body: MetaAnalysisRequest }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const { data, params } = request.body;
|
||||
|
||||
if (!Array.isArray(data) || data.length < 2) {
|
||||
return reply.status(400).send({ error: '至少需要 2 条研究数据' });
|
||||
}
|
||||
|
||||
if (!params?.data_type) {
|
||||
return reply.status(400).send({ error: '缺少 data_type 参数' });
|
||||
}
|
||||
|
||||
const result = await metaAnalysisService.runAnalysis({ data, params });
|
||||
return reply.send(result);
|
||||
}
|
||||
|
||||
export async function getProjectData(
|
||||
request: FastifyRequest<{ Params: { projectId: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const { projectId } = request.params;
|
||||
const { rows, detectedType } = await metaAnalysisService.getProjectExtractionData(projectId);
|
||||
return reply.send({ rows, detectedType, count: rows.length });
|
||||
}
|
||||
|
||||
export async function healthCheck(
|
||||
_request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const ok = await metaAnalysisService.healthCheck();
|
||||
return reply.send({ rServiceAvailable: ok });
|
||||
}
|
||||
16
backend/src/modules/asl/meta-analysis/routes/index.ts
Normal file
16
backend/src/modules/asl/meta-analysis/routes/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Meta Analysis Routes
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import * as ctrl from '../controllers/MetaAnalysisController.js';
|
||||
import { authenticate, requireModule } from '../../../../common/auth/auth.middleware.js';
|
||||
|
||||
export async function metaAnalysisRoutes(fastify: FastifyInstance) {
|
||||
fastify.addHook('preHandler', authenticate);
|
||||
fastify.addHook('preHandler', requireModule('ASL'));
|
||||
|
||||
fastify.post('/run', ctrl.runAnalysis);
|
||||
fastify.get('/project-data/:projectId', ctrl.getProjectData);
|
||||
fastify.get('/health', ctrl.healthCheck);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Meta Analysis Service
|
||||
*
|
||||
* Proxies meta-analysis requests to R Docker (ST_META_ANALYSIS).
|
||||
* Uses inline data transfer for small datasets typical in meta-analysis (5-30 rows).
|
||||
* Also supports extracting data from Tool 3 extraction results.
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { logger } from '../../../../common/logging/index.js';
|
||||
import { prisma } from '../../../../config/database.js';
|
||||
|
||||
export type MetaDataType = 'hr' | 'dichotomous' | 'continuous';
|
||||
export type MetaModel = 'random' | 'fixed';
|
||||
|
||||
export interface MetaAnalysisParams {
|
||||
data_type: MetaDataType;
|
||||
model?: MetaModel;
|
||||
effect_measure?: string;
|
||||
}
|
||||
|
||||
export interface MetaAnalysisRequest {
|
||||
data: Record<string, unknown>[];
|
||||
params: MetaAnalysisParams;
|
||||
}
|
||||
|
||||
const R_TOOL_CODE = 'ST_META_ANALYSIS';
|
||||
|
||||
class MetaAnalysisService {
|
||||
private client: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
const baseURL = process.env.R_SERVICE_URL || 'http://localhost:8082';
|
||||
this.client = axios.create({
|
||||
baseURL,
|
||||
timeout: 120_000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
async runAnalysis(req: MetaAnalysisRequest) {
|
||||
const startTime = Date.now();
|
||||
|
||||
const requestBody = {
|
||||
data_source: {
|
||||
type: 'inline',
|
||||
data: req.data,
|
||||
},
|
||||
params: req.params,
|
||||
};
|
||||
|
||||
logger.info('[META] Calling R service', {
|
||||
toolCode: R_TOOL_CODE,
|
||||
studyCount: req.data.length,
|
||||
dataType: req.params.data_type,
|
||||
model: req.params.model ?? 'random',
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await this.client.post(
|
||||
`/api/v1/skills/${R_TOOL_CODE}`,
|
||||
requestBody,
|
||||
);
|
||||
|
||||
const executionMs = Date.now() - startTime;
|
||||
|
||||
logger.info('[META] R service response', {
|
||||
status: response.data?.status,
|
||||
executionMs,
|
||||
});
|
||||
|
||||
return { ...response.data, executionMs };
|
||||
} catch (error: any) {
|
||||
const statusCode = error.response?.status;
|
||||
if (statusCode === 502 || statusCode === 504) {
|
||||
throw new Error('R 统计服务繁忙或超时,请稍后重试');
|
||||
}
|
||||
const userHint = error.response?.data?.user_hint;
|
||||
if (userHint) throw new Error(userHint);
|
||||
throw new Error(`R service error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch extraction results from a project and transform to meta-analysis data.
|
||||
* Only approved results with extractedData are included.
|
||||
*/
|
||||
async getProjectExtractionData(projectId: string): Promise<{
|
||||
rows: Record<string, unknown>[];
|
||||
detectedType: MetaDataType | null;
|
||||
}> {
|
||||
const results = await prisma.aslExtractionResult.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
reviewStatus: 'approved',
|
||||
status: 'completed',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
extractedData: true,
|
||||
snapshotFilename: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (results.length === 0) {
|
||||
return { rows: [], detectedType: null };
|
||||
}
|
||||
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
let detectedType: MetaDataType | null = null;
|
||||
|
||||
for (const r of results) {
|
||||
const flat = flattenExtractedData(r.extractedData);
|
||||
if (!flat) continue;
|
||||
|
||||
const row: Record<string, unknown> = {
|
||||
study_id: flat.study_id || r.snapshotFilename || r.id,
|
||||
};
|
||||
|
||||
if (flat.hr !== undefined) {
|
||||
detectedType = detectedType ?? 'hr';
|
||||
row.hr = parseNum(flat.hr);
|
||||
row.lower_ci = parseNum(flat.lower_ci ?? flat.ci_lower ?? flat.hr_lower);
|
||||
row.upper_ci = parseNum(flat.upper_ci ?? flat.ci_upper ?? flat.hr_upper);
|
||||
} else if (flat.events_e !== undefined || flat.event_e !== undefined) {
|
||||
detectedType = detectedType ?? 'dichotomous';
|
||||
row.events_e = parseNum(flat.events_e ?? flat.event_e);
|
||||
row.total_e = parseNum(flat.total_e ?? flat.n_e);
|
||||
row.events_c = parseNum(flat.events_c ?? flat.event_c);
|
||||
row.total_c = parseNum(flat.total_c ?? flat.n_c);
|
||||
} else if (flat.mean_e !== undefined) {
|
||||
detectedType = detectedType ?? 'continuous';
|
||||
row.mean_e = parseNum(flat.mean_e);
|
||||
row.sd_e = parseNum(flat.sd_e);
|
||||
row.n_e = parseNum(flat.n_e);
|
||||
row.mean_c = parseNum(flat.mean_c);
|
||||
row.sd_c = parseNum(flat.sd_c);
|
||||
row.n_c = parseNum(flat.n_c);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return { rows, detectedType };
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const res = await this.client.get('/health');
|
||||
return res.data.status === 'ok';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseNum(val: unknown): number | undefined {
|
||||
if (val === undefined || val === null || val === '') return undefined;
|
||||
const n = Number(val);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function flattenExtractedData(raw: unknown): Record<string, any> | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
|
||||
const data = raw as Record<string, any>;
|
||||
const flat: Record<string, any> = {};
|
||||
|
||||
for (const [moduleKey, moduleVal] of Object.entries(data)) {
|
||||
if (Array.isArray(moduleVal)) {
|
||||
for (const item of moduleVal) {
|
||||
if (item && typeof item === 'object' && 'key' in item) {
|
||||
flat[item.key] = item.value ?? item;
|
||||
}
|
||||
}
|
||||
} else if (moduleVal && typeof moduleVal === 'object') {
|
||||
for (const [k, v] of Object.entries(moduleVal as Record<string, any>)) {
|
||||
if (v && typeof v === 'object' && 'value' in v) {
|
||||
flat[k] = v.value;
|
||||
} else {
|
||||
flat[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(flat).length > 0 ? flat : null;
|
||||
}
|
||||
|
||||
export const metaAnalysisService = new MetaAnalysisService();
|
||||
@@ -10,6 +10,8 @@ import * as fulltextScreeningController from '../fulltext-screening/controllers/
|
||||
import * as researchController from '../controllers/researchController.js';
|
||||
import * as deepResearchController from '../controllers/deepResearchController.js';
|
||||
import { extractionRoutes } from '../extraction/routes/index.js';
|
||||
import { chartingRoutes } from '../charting/routes/index.js';
|
||||
import { metaAnalysisRoutes } from '../meta-analysis/routes/index.js';
|
||||
import { authenticate, requireModule } from '../../../common/auth/auth.middleware.js';
|
||||
|
||||
export async function aslRoutes(fastify: FastifyInstance) {
|
||||
@@ -111,6 +113,12 @@ export async function aslRoutes(fastify: FastifyInstance) {
|
||||
|
||||
// ==================== 工具 3:全文智能提取路由 ====================
|
||||
await fastify.register(extractionRoutes, { prefix: '/extraction' });
|
||||
|
||||
// ==================== 工具 4:SR 图表生成器路由 ====================
|
||||
await fastify.register(chartingRoutes, { prefix: '/charting' });
|
||||
|
||||
// ==================== 工具 5:Meta 分析引擎路由 ====================
|
||||
await fastify.register(metaAnalysisRoutes, { prefix: '/meta-analysis' });
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -463,12 +463,57 @@ export async function registerIitRoutes(fastify: FastifyInstance) {
|
||||
|
||||
logger.info('Registered route: POST /api/v1/iit/patient-wechat/callback');
|
||||
|
||||
// TODO: 后续添加其他路由
|
||||
// - 项目管理路由
|
||||
// - 影子状态路由
|
||||
// - 任务管理路由
|
||||
// - 患者绑定路由
|
||||
// - 患者信息查询路由
|
||||
// =============================================
|
||||
// Web Chat API(业务端 AI 对话)
|
||||
// =============================================
|
||||
const { getChatOrchestrator } = await import('../services/ChatOrchestrator.js');
|
||||
|
||||
fastify.post(
|
||||
'/api/v1/iit/chat',
|
||||
{
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['message'],
|
||||
properties: {
|
||||
message: { type: 'string' },
|
||||
userId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
reply: { type: 'string' },
|
||||
duration: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request: any, reply) => {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const { message, userId } = request.body;
|
||||
const uid = userId || request.user?.id || 'web-user';
|
||||
const orchestrator = await getChatOrchestrator();
|
||||
const replyText = await orchestrator.handleMessage(uid, message);
|
||||
|
||||
return reply.code(200).send({
|
||||
reply: replyText,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('Web chat failed', { error: error.message });
|
||||
return reply.code(500).send({
|
||||
reply: '系统处理出错,请稍后重试。',
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
logger.info('Registered route: POST /api/v1/iit/chat');
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user