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