feat(iit): QC deep fix + V3.1 architecture plan + project member management
QC System Deep Fix: - HardRuleEngine: add null tolerance + field availability pre-check (skipped status) - SkillRunner: baseline data merge for follow-up events + field availability check - QcReportService: record-level pass rate calculation + accurate LLM XML report - iitBatchController: legacy log cleanup (eventId=null) + upsert RecordSummary - seed-iit-qc-rules: null/empty string tolerance + applicableEvents config V3.1 Architecture Design (docs only, no code changes): - QC engine V3.1 plan: 5-level data structure (CDISC ODM) + D1-D7 dimensions - Three-batch implementation strategy (A: foundation, B: bubbling, C: new engines) - Architecture team review: 4 whitepapers reviewed + feedback doc + 4 critical suggestions - CRA Agent strategy roadmap + CRA 4-tool explanation doc for clinical experts Project Member Management: - Cross-tenant member search and assignment (remove tenant restriction) - IIT project detail page enhancement with tabbed layout (KB + members) - IitProjectContext for business-side project selection - System-KB route access control adjustment for project operators Frontend: - AdminLayout sidebar menu restructure - IitLayout with project context provider - IitMemberManagePage new component - Business-side pages adapt to project context Prisma: - 2 new migrations (user-project RBAC + is_demo flag) - Schema updates for project member management Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
-- Phase 2: User-Project Association + RBAC
|
||||
-- 1. IitUserMapping: add userId (nullable FK to platform_schema.users)
|
||||
-- 2. IitProject: add tenantId (nullable FK to platform_schema.tenants, for Phase 3)
|
||||
-- 3. UserRole enum: add IIT_OPERATOR role
|
||||
|
||||
-- 1. Add user_id to user_mappings (nullable, gradual migration)
|
||||
ALTER TABLE "iit_schema"."user_mappings" ADD COLUMN IF NOT EXISTS "user_id" VARCHAR(255);
|
||||
ALTER TABLE "iit_schema"."user_mappings" ADD CONSTRAINT "user_mappings_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "platform_schema"."users"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
CREATE INDEX IF NOT EXISTS "idx_iit_user_mappings_user_id" ON "iit_schema"."user_mappings"("user_id");
|
||||
|
||||
-- 2. Add tenant_id to projects (nullable, Phase 3 will make it required)
|
||||
ALTER TABLE "iit_schema"."projects" ADD COLUMN IF NOT EXISTS "tenant_id" VARCHAR(255);
|
||||
ALTER TABLE "iit_schema"."projects" ADD CONSTRAINT "projects_tenant_id_fkey"
|
||||
FOREIGN KEY ("tenant_id") REFERENCES "platform_schema"."tenants"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
CREATE INDEX IF NOT EXISTS "idx_iit_projects_tenant_id" ON "iit_schema"."projects"("tenant_id");
|
||||
|
||||
-- 3. Add IIT_OPERATOR to UserRole enum
|
||||
ALTER TYPE "platform_schema"."UserRole" ADD VALUE IF NOT EXISTS 'IIT_OPERATOR' BEFORE 'USER';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add is_demo flag to IIT projects (体验项目标记)
|
||||
ALTER TABLE iit_schema.projects ADD COLUMN IF NOT EXISTS is_demo BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -46,6 +46,7 @@ model User {
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
tenant_members tenant_members[]
|
||||
user_modules user_modules[]
|
||||
iitUserMappings IitUserMapping[]
|
||||
departments departments? @relation(fields: [department_id], references: [id])
|
||||
tenants tenants @relation(fields: [tenant_id], references: [id])
|
||||
|
||||
@@ -950,17 +951,21 @@ model IitProject {
|
||||
lastSyncAt DateTime? @map("last_sync_at")
|
||||
cronEnabled Boolean @default(false) @map("cron_enabled")
|
||||
cronExpression String? @map("cron_expression")
|
||||
isDemo Boolean @default(false) @map("is_demo")
|
||||
status String @default("active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
tenantId String? @map("tenant_id")
|
||||
auditLogs IitAuditLog[]
|
||||
pendingActions IitPendingAction[]
|
||||
taskRuns IitTaskRun[]
|
||||
userMappings IitUserMapping[]
|
||||
tenant tenants? @relation(fields: [tenantId], references: [id])
|
||||
|
||||
@@index([status, deletedAt])
|
||||
@@index([knowledgeBaseId], map: "idx_iit_project_kb")
|
||||
@@index([tenantId])
|
||||
@@map("projects")
|
||||
@@schema("iit_schema")
|
||||
}
|
||||
@@ -1022,6 +1027,7 @@ model IitUserMapping {
|
||||
id String @id @default(uuid())
|
||||
projectId String @map("project_id")
|
||||
systemUserId String @map("system_user_id")
|
||||
userId String? @map("user_id")
|
||||
redcapUsername String @map("redcap_username")
|
||||
wecomUserId String? @map("wecom_user_id")
|
||||
miniProgramOpenId String? @unique @map("mini_program_open_id")
|
||||
@@ -1030,11 +1036,13 @@ model IitUserMapping {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
project IitProject @relation(fields: [projectId], references: [id])
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
|
||||
@@unique([projectId, systemUserId])
|
||||
@@unique([projectId, redcapUsername])
|
||||
@@index([wecomUserId])
|
||||
@@index([miniProgramOpenId])
|
||||
@@index([userId])
|
||||
@@map("user_mappings")
|
||||
@@schema("iit_schema")
|
||||
}
|
||||
@@ -1763,6 +1771,7 @@ model tenants {
|
||||
tenant_quotas tenant_quotas[]
|
||||
users User[]
|
||||
user_modules user_modules[]
|
||||
iitProjects IitProject[]
|
||||
|
||||
@@index([code], map: "idx_tenants_code")
|
||||
@@index([status], map: "idx_tenants_status")
|
||||
@@ -1819,6 +1828,7 @@ enum UserRole {
|
||||
HOSPITAL_ADMIN
|
||||
PHARMA_ADMIN
|
||||
DEPARTMENT_ADMIN
|
||||
IIT_OPERATOR
|
||||
USER
|
||||
|
||||
@@schema("platform_schema")
|
||||
|
||||
@@ -27,60 +27,95 @@ const INCLUSION_RULES = [
|
||||
name: '年龄范围检查',
|
||||
field: 'age',
|
||||
logic: {
|
||||
and: [
|
||||
{ '>=': [{ var: 'age' }, 16] },
|
||||
{ '<=': [{ var: 'age' }, 35] }
|
||||
or: [
|
||||
{ '==': [{ var: 'age' }, null] },
|
||||
{ '==': [{ var: 'age' }, ''] },
|
||||
{
|
||||
and: [
|
||||
{ '>=': [{ var: 'age' }, 16] },
|
||||
{ '<=': [{ var: 'age' }, 35] }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
message: '年龄不在 16-35 岁范围内',
|
||||
severity: 'error',
|
||||
category: 'inclusion'
|
||||
category: 'inclusion',
|
||||
applicableEvents: [] as string[],
|
||||
},
|
||||
{
|
||||
id: 'inc_002',
|
||||
name: '出生日期范围检查',
|
||||
field: 'birth_date',
|
||||
logic: {
|
||||
and: [
|
||||
{ '>=': [{ var: 'birth_date' }, '1989-01-01'] },
|
||||
{ '<=': [{ var: 'birth_date' }, '2008-01-01'] }
|
||||
or: [
|
||||
{ '==': [{ var: 'birth_date' }, null] },
|
||||
{ '==': [{ var: 'birth_date' }, ''] },
|
||||
{
|
||||
and: [
|
||||
{ '>=': [{ var: 'birth_date' }, '1989-01-01'] },
|
||||
{ '<=': [{ var: 'birth_date' }, '2008-01-01'] }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
message: '出生日期不在 1989-01-01 至 2008-01-01 范围内',
|
||||
severity: 'error',
|
||||
category: 'inclusion'
|
||||
category: 'inclusion',
|
||||
applicableEvents: [] as string[],
|
||||
},
|
||||
{
|
||||
id: 'inc_003',
|
||||
name: '月经周期规律性检查',
|
||||
field: 'menstrual_cycle',
|
||||
logic: {
|
||||
and: [
|
||||
{ '>=': [{ var: 'menstrual_cycle' }, 21] },
|
||||
{ '<=': [{ var: 'menstrual_cycle' }, 35] }
|
||||
or: [
|
||||
{ '==': [{ var: 'menstrual_cycle' }, null] },
|
||||
{ '==': [{ var: 'menstrual_cycle' }, ''] },
|
||||
{
|
||||
and: [
|
||||
{ '>=': [{ var: 'menstrual_cycle' }, 21] },
|
||||
{ '<=': [{ var: 'menstrual_cycle' }, 35] }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
message: '月经周期不在 21-35 天范围内(28±7天)',
|
||||
severity: 'error',
|
||||
category: 'inclusion'
|
||||
category: 'inclusion',
|
||||
applicableEvents: [] as string[],
|
||||
},
|
||||
{
|
||||
id: 'inc_004',
|
||||
name: 'VAS 评分检查',
|
||||
field: 'vas_score',
|
||||
logic: { '>=': [{ var: 'vas_score' }, 4] },
|
||||
logic: {
|
||||
or: [
|
||||
{ '==': [{ var: 'vas_score' }, null] },
|
||||
{ '==': [{ var: 'vas_score' }, ''] },
|
||||
{ '>=': [{ var: 'vas_score' }, 4] }
|
||||
]
|
||||
},
|
||||
message: 'VAS 疼痛评分 < 4 分,不符合入组条件',
|
||||
severity: 'error',
|
||||
category: 'inclusion'
|
||||
category: 'inclusion',
|
||||
applicableEvents: [] as string[],
|
||||
},
|
||||
{
|
||||
id: 'inc_005',
|
||||
name: '知情同意书签署检查',
|
||||
field: 'informed_consent',
|
||||
logic: { '==': [{ var: 'informed_consent' }, 1] },
|
||||
logic: {
|
||||
or: [
|
||||
{ '==': [{ var: 'informed_consent' }, null] },
|
||||
{ '==': [{ var: 'informed_consent' }, ''] },
|
||||
{ '==': [{ var: 'informed_consent' }, 1] }
|
||||
]
|
||||
},
|
||||
message: '未签署知情同意书',
|
||||
severity: 'error',
|
||||
category: 'inclusion'
|
||||
category: 'inclusion',
|
||||
applicableEvents: [] as string[],
|
||||
}
|
||||
];
|
||||
|
||||
@@ -92,37 +127,65 @@ const EXCLUSION_RULES = [
|
||||
id: 'exc_001',
|
||||
name: '继发性痛经排除',
|
||||
field: 'secondary_dysmenorrhea',
|
||||
logic: { '!=': [{ var: 'secondary_dysmenorrhea' }, 1] },
|
||||
logic: {
|
||||
or: [
|
||||
{ '==': [{ var: 'secondary_dysmenorrhea' }, null] },
|
||||
{ '==': [{ var: 'secondary_dysmenorrhea' }, ''] },
|
||||
{ '!=': [{ var: 'secondary_dysmenorrhea' }, 1] }
|
||||
]
|
||||
},
|
||||
message: '存在继发性痛经(盆腔炎、子宫内膜异位症、子宫腺肌病等)',
|
||||
severity: 'error',
|
||||
category: 'exclusion'
|
||||
category: 'exclusion',
|
||||
applicableEvents: [] as string[],
|
||||
},
|
||||
{
|
||||
id: 'exc_002',
|
||||
name: '妊娠哺乳期排除',
|
||||
field: 'pregnancy_lactation',
|
||||
logic: { '!=': [{ var: 'pregnancy_lactation' }, 1] },
|
||||
logic: {
|
||||
or: [
|
||||
{ '==': [{ var: 'pregnancy_lactation' }, null] },
|
||||
{ '==': [{ var: 'pregnancy_lactation' }, ''] },
|
||||
{ '!=': [{ var: 'pregnancy_lactation' }, 1] }
|
||||
]
|
||||
},
|
||||
message: '妊娠或哺乳期妇女,不符合入组条件',
|
||||
severity: 'error',
|
||||
category: 'exclusion'
|
||||
category: 'exclusion',
|
||||
applicableEvents: [] as string[],
|
||||
},
|
||||
{
|
||||
id: 'exc_003',
|
||||
name: '严重疾病排除',
|
||||
field: 'severe_disease',
|
||||
logic: { '!=': [{ var: 'severe_disease' }, 1] },
|
||||
logic: {
|
||||
or: [
|
||||
{ '==': [{ var: 'severe_disease' }, null] },
|
||||
{ '==': [{ var: 'severe_disease' }, ''] },
|
||||
{ '!=': [{ var: 'severe_disease' }, 1] }
|
||||
]
|
||||
},
|
||||
message: '合并有心脑血管、肝、肾、造血系统等严重疾病或精神病',
|
||||
severity: 'error',
|
||||
category: 'exclusion'
|
||||
category: 'exclusion',
|
||||
applicableEvents: [] as string[],
|
||||
},
|
||||
{
|
||||
id: 'exc_004',
|
||||
name: '月经周期不规律排除',
|
||||
field: 'irregular_menstruation',
|
||||
logic: { '!=': [{ var: 'irregular_menstruation' }, 1] },
|
||||
logic: {
|
||||
or: [
|
||||
{ '==': [{ var: 'irregular_menstruation' }, null] },
|
||||
{ '==': [{ var: 'irregular_menstruation' }, ''] },
|
||||
{ '!=': [{ var: 'irregular_menstruation' }, 1] }
|
||||
]
|
||||
},
|
||||
message: '月经周期不规律或间歇性痛经发作',
|
||||
severity: 'error',
|
||||
category: 'exclusion'
|
||||
category: 'exclusion',
|
||||
applicableEvents: [] as string[],
|
||||
}
|
||||
];
|
||||
|
||||
@@ -242,7 +305,7 @@ async function main() {
|
||||
|
||||
// 1. 先获取项目 ID
|
||||
const project = await prisma.iitProject.findFirst({
|
||||
where: { name: 'test0102' }
|
||||
where: { name: { in: ['test0207', 'test0102'] } }
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
|
||||
553
backend/scripts/test-qc-pipeline.ts
Normal file
553
backend/scripts/test-qc-pipeline.ts
Normal file
@@ -0,0 +1,553 @@
|
||||
/**
|
||||
* IIT 质控全链路诊断脚本
|
||||
*
|
||||
* 从 REDCap 原始数据 → 质控规则执行 → 数据库存储 → LLM 报告生成,
|
||||
* 端到端可视化验证整个 QC 管线。
|
||||
*
|
||||
* 用法:npx tsx scripts/test-qc-pipeline.ts
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { RedcapAdapter } from '../src/modules/iit-manager/adapters/RedcapAdapter.js';
|
||||
import { createSkillRunner } from '../src/modules/iit-manager/engines/SkillRunner.js';
|
||||
import { QcReportService } from '../src/modules/iit-manager/services/QcReportService.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const PROJECT_ID = 'test0102-pd-study';
|
||||
|
||||
// ─── 工具函数 ────────────────────────────────────────────────
|
||||
const SEP = '═'.repeat(80);
|
||||
const SEP2 = '─'.repeat(80);
|
||||
|
||||
function section(title: string) {
|
||||
console.log(`\n${SEP}`);
|
||||
console.log(` ${title}`);
|
||||
console.log(SEP);
|
||||
}
|
||||
|
||||
function sub(title: string) {
|
||||
console.log(`\n${SEP2}`);
|
||||
console.log(` ${title}`);
|
||||
console.log(SEP2);
|
||||
}
|
||||
|
||||
function table(rows: Record<string, any>[], maxRows = 5) {
|
||||
if (rows.length === 0) { console.log(' (空)'); return; }
|
||||
const display = rows.slice(0, maxRows);
|
||||
console.table(display);
|
||||
if (rows.length > maxRows) {
|
||||
console.log(` ... 共 ${rows.length} 行,仅展示前 ${maxRows} 行`);
|
||||
}
|
||||
}
|
||||
|
||||
function jsonPreview(obj: any, maxLen = 2000) {
|
||||
const str = JSON.stringify(obj, null, 2);
|
||||
if (str.length <= maxLen) {
|
||||
console.log(str);
|
||||
} else {
|
||||
console.log(str.substring(0, maxLen));
|
||||
console.log(`\n ... (截断,总长 ${str.length} 字符)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Part 1:REDCap 原始数据 ─────────────────────────────────
|
||||
async function part1_redcapRawData() {
|
||||
section('Part 1:REDCap 原始数据');
|
||||
|
||||
const project = await prisma.iitProject.findUnique({
|
||||
where: { id: PROJECT_ID },
|
||||
select: { id: true, name: true, redcapUrl: true, redcapApiToken: true },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
console.log('❌ 项目不存在:', PROJECT_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(` 项目: ${project.name} (${project.id})`);
|
||||
console.log(` REDCap: ${project.redcapUrl}`);
|
||||
|
||||
const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken);
|
||||
|
||||
// 先检测 REDCap 是否可达
|
||||
let redcapAvailable = true;
|
||||
try {
|
||||
await adapter.exportRecords({ fields: ['record_id'] });
|
||||
} catch {
|
||||
redcapAvailable = false;
|
||||
console.log('\n ⚠️ REDCap 服务不可达,跳过 REDCap 数据拉取,仅验证数据库数据。');
|
||||
console.log(' 请确保 REDCap (localhost:8080) 已启动后再次运行。');
|
||||
return;
|
||||
}
|
||||
|
||||
// 1a. 事件映射
|
||||
sub('1a. 表单-事件映射 (Form-Event Mapping)');
|
||||
try {
|
||||
const formEventMapping = await adapter.getFormEventMapping();
|
||||
table(formEventMapping, 20);
|
||||
} catch (e: any) {
|
||||
console.log(` ⚠️ 获取失败: ${e.message}(可能不是纵向研究项目)`);
|
||||
}
|
||||
|
||||
// 1b. 所有 instruments
|
||||
sub('1b. REDCap 表单列表 (Instruments)');
|
||||
try {
|
||||
const instruments = await adapter.exportInstruments();
|
||||
table(instruments, 20);
|
||||
} catch (e: any) {
|
||||
console.log(` ⚠️ 获取失败: ${e.message}`);
|
||||
}
|
||||
|
||||
// 1c. 原始记录
|
||||
sub('1c. REDCap 原始记录 (Raw Records - 前 5 行)');
|
||||
let rawRecords: any[] = [];
|
||||
try {
|
||||
rawRecords = await adapter.exportRecords({});
|
||||
console.log(` 总行数: ${rawRecords.length}`);
|
||||
for (let i = 0; i < Math.min(5, rawRecords.length); i++) {
|
||||
console.log(`\n --- Record ${i + 1} ---`);
|
||||
jsonPreview(rawRecords[i], 1500);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.log(` ⚠️ 获取失败: ${e.message}`);
|
||||
}
|
||||
|
||||
// 1d. 事件级数据
|
||||
sub('1d. 事件级数据 (getAllRecordsByEvent)');
|
||||
let eventRecords: Array<{ recordId: string; eventName: string; eventLabel: string; forms: string[]; data: Record<string, any> }> = [];
|
||||
try {
|
||||
eventRecords = await adapter.getAllRecordsByEvent({});
|
||||
console.log(` 总 record+event 组合数: ${eventRecords.length}`);
|
||||
|
||||
const uniqueRecords = new Set(eventRecords.map(r => r.recordId));
|
||||
const uniqueEvents = new Set(eventRecords.map(r => r.eventName));
|
||||
console.log(` 唯一 record 数: ${uniqueRecords.size}`);
|
||||
console.log(` 唯一 event 数: ${uniqueEvents.size}`);
|
||||
console.log(` 事件列表: ${[...uniqueEvents].join(', ')}`);
|
||||
} catch (e: any) {
|
||||
console.log(` ⚠️ 获取失败: ${e.message}(可能不是纵向研究设计)`);
|
||||
|
||||
// 回退:把 rawRecords 当作单事件处理
|
||||
if (rawRecords.length > 0) {
|
||||
console.log(' 📌 回退:使用 exportRecords 数据作为平铺记录');
|
||||
const uniqueIds = new Set(rawRecords.map(r => r.record_id));
|
||||
eventRecords = rawRecords.map(r => ({
|
||||
recordId: r.record_id,
|
||||
eventName: r.redcap_event_name || 'default',
|
||||
eventLabel: r.redcap_event_name || 'default',
|
||||
forms: [],
|
||||
data: r,
|
||||
}));
|
||||
console.log(` 唯一 record 数: ${uniqueIds.size}, 总行数: ${eventRecords.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (eventRecords.length > 0) {
|
||||
// 1e. Record-Event 分布
|
||||
const recordGroups = new Map<string, typeof eventRecords>();
|
||||
for (const r of eventRecords) {
|
||||
if (!recordGroups.has(r.recordId)) recordGroups.set(r.recordId, []);
|
||||
recordGroups.get(r.recordId)!.push(r);
|
||||
}
|
||||
|
||||
const recordEventSummary: Array<{ recordId: string; events: string; dataFieldCount: number }> = [];
|
||||
for (const [recordId, events] of recordGroups) {
|
||||
recordEventSummary.push({
|
||||
recordId,
|
||||
events: events.map(e => e.eventLabel || e.eventName).join(' | '),
|
||||
dataFieldCount: events.reduce((sum, e) => sum + Object.keys(e.data).length, 0),
|
||||
});
|
||||
}
|
||||
sub('1e. Record-Event 分布');
|
||||
table(recordEventSummary, 20);
|
||||
|
||||
// 1f. 纳入/排除关键字段可用性
|
||||
sub('1f. 纳入/排除关键字段可用性检查');
|
||||
const keyFields = ['age', 'birth_date', 'menstrual_cycle', 'vas_score', 'informed_consent',
|
||||
'secondary_dysmenorrhea', 'pregnancy_lactation', 'severe_disease', 'irregular_menstruation'];
|
||||
|
||||
const fieldAvailability: Array<{ recordId: string; event: string; [key: string]: any }> = [];
|
||||
for (const r of eventRecords.slice(0, 30)) {
|
||||
const row: any = { recordId: r.recordId, event: r.eventLabel || r.eventName };
|
||||
for (const f of keyFields) {
|
||||
const val = r.data[f];
|
||||
row[f] = val === undefined || val === null || val === '' ? '❌' : `✅ ${val}`;
|
||||
}
|
||||
fieldAvailability.push(row);
|
||||
}
|
||||
table(fieldAvailability, 30);
|
||||
}
|
||||
|
||||
return { eventRecords, rawRecords };
|
||||
}
|
||||
|
||||
// ─── Part 2:执行质控 + 数据库存储验证 ─────────────────────
|
||||
async function part2_qcExecutionAndStorage() {
|
||||
section('Part 2:质控执行与数据库存储验证');
|
||||
|
||||
// 2a. 查看当前 QC 规则
|
||||
sub('2a. 当前加载的 QC 规则');
|
||||
const skill = await prisma.iitSkill.findFirst({
|
||||
where: { projectId: PROJECT_ID, skillType: 'qc_process', isActive: true },
|
||||
select: { id: true, name: true, config: true },
|
||||
});
|
||||
|
||||
if (!skill) {
|
||||
console.log('❌ 未找到 QC 规则');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = skill.config as any;
|
||||
const rules = config?.rules || [];
|
||||
console.log(` 规则总数: ${rules.length}`);
|
||||
|
||||
const ruleOverview = rules.map((r: any) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
category: r.category,
|
||||
severity: r.severity,
|
||||
field: Array.isArray(r.field) ? r.field.join(',') : r.field,
|
||||
hasNullTolerance: JSON.stringify(r.logic).includes('"=="') && JSON.stringify(r.logic).includes('null') ? '✅' : '❌',
|
||||
}));
|
||||
table(ruleOverview, 40);
|
||||
|
||||
// 2b. 执行质控前 - 记录当前 qc_logs 行数
|
||||
sub('2b. 执行质控前 - 数据库状态');
|
||||
const logCountBefore = await prisma.iitQcLog.count({ where: { projectId: PROJECT_ID } });
|
||||
console.log(` qc_logs 现有行数: ${logCountBefore}`);
|
||||
|
||||
const statsBefore = await prisma.iitQcProjectStats.findUnique({ where: { projectId: PROJECT_ID } });
|
||||
if (statsBefore) {
|
||||
console.log(` project_stats: total=${statsBefore.totalRecords}, pass=${statsBefore.passedRecords}, fail=${statsBefore.failedRecords}, warn=${statsBefore.warningRecords}`);
|
||||
}
|
||||
|
||||
// 2c. 清理旧版日志(event_id 为 NULL 的遗留数据)
|
||||
sub('2c-0. 清理旧版质控日志 (event_id IS NULL)');
|
||||
const deletedLegacy = await prisma.iitQcLog.deleteMany({
|
||||
where: { projectId: PROJECT_ID, eventId: null }
|
||||
});
|
||||
console.log(` 删除旧版日志: ${deletedLegacy.count} 条`);
|
||||
|
||||
// 2c. 执行全量质控
|
||||
sub('2c. 执行全量质控 (SkillRunner.runByTrigger)');
|
||||
const runner = createSkillRunner(PROJECT_ID);
|
||||
const startTime = Date.now();
|
||||
const results = await runner.runByTrigger('manual');
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
console.log(` 耗时: ${duration}ms`);
|
||||
console.log(` 结果总数 (record+event 组合): ${results.length}`);
|
||||
|
||||
// 状态分布
|
||||
const statusDist: Record<string, number> = {};
|
||||
for (const r of results) {
|
||||
statusDist[r.overallStatus] = (statusDist[r.overallStatus] || 0) + 1;
|
||||
}
|
||||
console.log(` 事件级状态分布: ${JSON.stringify(statusDist)}`);
|
||||
|
||||
// record 级别聚合
|
||||
const statusPriority: Record<string, number> = { 'FAIL': 3, 'WARNING': 2, 'UNCERTAIN': 1, 'PASS': 0 };
|
||||
const recordWorst = new Map<string, string>();
|
||||
for (const r of results) {
|
||||
const existing = recordWorst.get(r.recordId);
|
||||
const curP = statusPriority[r.overallStatus] ?? 0;
|
||||
const exP = existing ? (statusPriority[existing] ?? 0) : -1;
|
||||
if (curP > exP) recordWorst.set(r.recordId, r.overallStatus);
|
||||
}
|
||||
|
||||
const recordStatusDist: Record<string, number> = {};
|
||||
for (const s of recordWorst.values()) {
|
||||
recordStatusDist[s] = (recordStatusDist[s] || 0) + 1;
|
||||
}
|
||||
console.log(` Record 级别状态分布: ${JSON.stringify(recordStatusDist)}`);
|
||||
const totalRec = recordWorst.size;
|
||||
const passRec = recordStatusDist['PASS'] || 0;
|
||||
console.log(` 通过率 (record级): ${totalRec > 0 ? ((passRec / totalRec) * 100).toFixed(1) : 0}%`);
|
||||
|
||||
// V3.2: 用批量结果更新 record_summary(覆盖旧状态)
|
||||
for (const [recordId, worstStatus] of recordWorst) {
|
||||
await prisma.iitRecordSummary.upsert({
|
||||
where: { projectId_recordId: { projectId: PROJECT_ID, recordId } },
|
||||
create: {
|
||||
projectId: PROJECT_ID, recordId,
|
||||
lastUpdatedAt: new Date(), latestQcStatus: worstStatus,
|
||||
latestQcAt: new Date(), formStatus: {}, updateCount: 1
|
||||
},
|
||||
update: { latestQcStatus: worstStatus, latestQcAt: new Date() }
|
||||
});
|
||||
}
|
||||
|
||||
// 展示每个 result 的详情
|
||||
sub('2d. 各 record+event 质控详情');
|
||||
const resultSummary = results.map(r => ({
|
||||
recordId: r.recordId,
|
||||
event: r.eventLabel || r.eventName || '-',
|
||||
status: r.overallStatus,
|
||||
issueCount: r.allIssues.length,
|
||||
criticalCount: r.criticalIssues.length,
|
||||
warningCount: r.warningIssues.length,
|
||||
issues: r.allIssues.slice(0, 3).map(i => `[${i.ruleId}] ${i.ruleName}`).join('; ') || '(无)',
|
||||
}));
|
||||
table(resultSummary, 50);
|
||||
|
||||
// 2e. 验证数据库写入
|
||||
sub('2e. 执行质控后 - 数据库状态');
|
||||
const logCountAfter = await prisma.iitQcLog.count({ where: { projectId: PROJECT_ID } });
|
||||
console.log(` qc_logs 行数: ${logCountBefore} → ${logCountAfter} (新增 ${logCountAfter - logCountBefore})`);
|
||||
|
||||
// 2f. 验证 DISTINCT ON 只取最新
|
||||
sub('2f. 验证去重逻辑 - DISTINCT ON (record_id, event_id) 只取最新');
|
||||
const latestLogs = await prisma.$queryRawUnsafe<any[]>(`
|
||||
SELECT DISTINCT ON (record_id, COALESCE(event_id, ''))
|
||||
record_id, event_id, status, created_at,
|
||||
(SELECT COUNT(*) FROM iit_schema.qc_logs t2
|
||||
WHERE t2.project_id = t1.project_id
|
||||
AND t2.record_id = t1.record_id
|
||||
AND COALESCE(t2.event_id, '') = COALESCE(t1.event_id, '')
|
||||
) as total_versions
|
||||
FROM iit_schema.qc_logs t1
|
||||
WHERE project_id = '${PROJECT_ID}'
|
||||
ORDER BY record_id, COALESCE(event_id, ''), created_at DESC
|
||||
`);
|
||||
|
||||
console.log(` 去重后的 record+event 组合数: ${latestLogs.length}`);
|
||||
|
||||
const dedup = latestLogs.map((l: any) => ({
|
||||
record_id: l.record_id,
|
||||
event_id: l.event_id || '-',
|
||||
status: l.status,
|
||||
total_versions: Number(l.total_versions),
|
||||
created_at: l.created_at,
|
||||
}));
|
||||
table(dedup, 30);
|
||||
|
||||
const hasMultipleVersions = dedup.some(d => d.total_versions > 1);
|
||||
console.log(` 是否存在多版本: ${hasMultipleVersions ? '✅ 是(去重逻辑生效)' : '仅单版本'}`);
|
||||
|
||||
// 2g. 查看一条 qc_log 的完整 issues 内容
|
||||
sub('2g. qc_log issues 字段样本(取第一条有 issues 的)');
|
||||
const sampleLog = await prisma.iitQcLog.findFirst({
|
||||
where: { projectId: PROJECT_ID, status: { not: 'PASS' } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { recordId: true, eventId: true, status: true, issues: true, createdAt: true },
|
||||
});
|
||||
|
||||
if (sampleLog) {
|
||||
console.log(` recordId: ${sampleLog.recordId}, eventId: ${sampleLog.eventId}, status: ${sampleLog.status}`);
|
||||
console.log(` issues 内容:`);
|
||||
jsonPreview(sampleLog.issues, 3000);
|
||||
} else {
|
||||
console.log(' ✅ 所有日志都是 PASS(没有 issues)');
|
||||
}
|
||||
|
||||
// 2h. record_summary 表验证
|
||||
sub('2h. record_summary 表内容');
|
||||
const summaries = await prisma.iitRecordSummary.findMany({
|
||||
where: { projectId: PROJECT_ID },
|
||||
select: { recordId: true, latestQcStatus: true, latestQcAt: true, completionRate: true, totalForms: true, completedForms: true },
|
||||
orderBy: { recordId: 'asc' },
|
||||
});
|
||||
table(summaries.map(s => ({
|
||||
recordId: s.recordId,
|
||||
qcStatus: s.latestQcStatus || '-',
|
||||
qcAt: s.latestQcAt ? s.latestQcAt.toISOString().replace('T', ' ').substring(0, 19) : '-',
|
||||
completionRate: s.completionRate != null ? `${s.completionRate}%` : '-',
|
||||
})), 20);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Part 3:LLM 报告 ───────────────────────────────────────
|
||||
async function part3_llmReport() {
|
||||
section('Part 3:最终呈现给 LLM 的质控报告');
|
||||
|
||||
// 3a. 强制生成新报告
|
||||
sub('3a. 调用 QcReportService.getReport (forceRefresh=true)');
|
||||
const report = await QcReportService.getReport(PROJECT_ID, { forceRefresh: true });
|
||||
|
||||
console.log(` reportType: ${report.reportType}`);
|
||||
console.log(` generatedAt: ${report.generatedAt}`);
|
||||
|
||||
// 3b. Summary 统计
|
||||
sub('3b. 报告 Summary');
|
||||
console.log(JSON.stringify(report.summary, null, 2));
|
||||
|
||||
// 3c. Critical Issues
|
||||
sub('3c. 严重问题 (Critical Issues)');
|
||||
console.log(` 总数: ${report.criticalIssues.length}`);
|
||||
table(report.criticalIssues.map(i => ({
|
||||
recordId: i.recordId,
|
||||
ruleId: i.ruleId,
|
||||
ruleName: i.ruleName,
|
||||
severity: i.severity,
|
||||
actualValue: i.actualValue ?? '空',
|
||||
expectedValue: i.expectedValue ?? '-',
|
||||
})), 30);
|
||||
|
||||
// 3d. Warning Issues
|
||||
sub('3d. 警告问题 (Warning Issues)');
|
||||
console.log(` 总数: ${report.warningIssues.length}`);
|
||||
table(report.warningIssues.map(i => ({
|
||||
recordId: i.recordId,
|
||||
ruleId: i.ruleId,
|
||||
ruleName: i.ruleName,
|
||||
actualValue: i.actualValue ?? '空',
|
||||
})), 20);
|
||||
|
||||
// 3e. Top Issues
|
||||
sub('3e. Top Issues');
|
||||
table(report.topIssues, 10);
|
||||
|
||||
// 3f. LLM XML 报告全文
|
||||
sub('3f. LLM XML 报告全文 (llmFriendlyXml)');
|
||||
console.log(report.llmFriendlyXml);
|
||||
|
||||
// 3g. 验证数据库 qc_reports 表
|
||||
sub('3g. qc_reports 缓存表验证');
|
||||
const cachedReports = await prisma.iitQcReport.findMany({
|
||||
where: { projectId: PROJECT_ID },
|
||||
select: { id: true, reportType: true, generatedAt: true, expiresAt: true },
|
||||
orderBy: { generatedAt: 'desc' },
|
||||
take: 5,
|
||||
});
|
||||
table(cachedReports.map(r => ({
|
||||
id: r.id.substring(0, 8) + '...',
|
||||
reportType: r.reportType,
|
||||
generatedAt: r.generatedAt.toISOString().replace('T', ' ').substring(0, 19),
|
||||
expiresAt: r.expiresAt?.toISOString().replace('T', ' ').substring(0, 19) || '-',
|
||||
})), 10);
|
||||
}
|
||||
|
||||
// ─── 回退:仅查看数据库现有 QC 数据 ─────────────────────────
|
||||
async function part2_dbOnly() {
|
||||
section('Part 2 (回退):数据库现有 QC 数据查看');
|
||||
|
||||
const logCount = await prisma.iitQcLog.count({ where: { projectId: PROJECT_ID } });
|
||||
console.log(` qc_logs 总行数: ${logCount}`);
|
||||
|
||||
if (logCount === 0) {
|
||||
console.log(' ⚠️ 没有质控日志,请先执行一键全量质控');
|
||||
return;
|
||||
}
|
||||
|
||||
sub('数据库 qc_logs 去重后最新状态');
|
||||
const latestLogs = await prisma.$queryRawUnsafe<any[]>(`
|
||||
SELECT DISTINCT ON (record_id, COALESCE(event_id, ''))
|
||||
record_id, event_id, status, issues, created_at
|
||||
FROM iit_schema.qc_logs
|
||||
WHERE project_id = '${PROJECT_ID}'
|
||||
ORDER BY record_id, COALESCE(event_id, ''), created_at DESC
|
||||
`);
|
||||
|
||||
const dedup = latestLogs.map((l: any) => ({
|
||||
record_id: l.record_id,
|
||||
event_id: l.event_id || '-',
|
||||
status: l.status,
|
||||
created_at: l.created_at,
|
||||
}));
|
||||
table(dedup, 30);
|
||||
|
||||
sub('record_summary 表');
|
||||
const summaries = await prisma.iitRecordSummary.findMany({
|
||||
where: { projectId: PROJECT_ID },
|
||||
select: { recordId: true, latestQcStatus: true, latestQcAt: true, completionRate: true },
|
||||
orderBy: { recordId: 'asc' },
|
||||
});
|
||||
table(summaries.map(s => ({
|
||||
recordId: s.recordId,
|
||||
qcStatus: s.latestQcStatus || '-',
|
||||
completionRate: s.completionRate != null ? `${s.completionRate}%` : '-',
|
||||
})), 20);
|
||||
|
||||
sub('project_stats 表');
|
||||
const stats = await prisma.iitQcProjectStats.findUnique({ where: { projectId: PROJECT_ID } });
|
||||
if (stats) {
|
||||
console.log(JSON.stringify({
|
||||
totalRecords: stats.totalRecords,
|
||||
passedRecords: stats.passedRecords,
|
||||
failedRecords: stats.failedRecords,
|
||||
warningRecords: stats.warningRecords,
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
sub('qc_log issues 字段样本');
|
||||
const sampleLog = await prisma.iitQcLog.findFirst({
|
||||
where: { projectId: PROJECT_ID, status: { not: 'PASS' } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { recordId: true, eventId: true, status: true, issues: true },
|
||||
});
|
||||
if (sampleLog) {
|
||||
console.log(` recordId: ${sampleLog.recordId}, eventId: ${sampleLog.eventId}, status: ${sampleLog.status}`);
|
||||
jsonPreview(sampleLog.issues, 3000);
|
||||
} else {
|
||||
console.log(' ✅ 所有日志都是 PASS');
|
||||
}
|
||||
}
|
||||
|
||||
async function part3_dbOnly() {
|
||||
section('Part 3 (回退):从数据库缓存读取报告');
|
||||
|
||||
const cached = await prisma.iitQcReport.findFirst({
|
||||
where: { projectId: PROJECT_ID },
|
||||
orderBy: { generatedAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!cached) {
|
||||
console.log(' ⚠️ 数据库中没有缓存报告');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(` reportType: ${cached.reportType}`);
|
||||
console.log(` generatedAt: ${cached.generatedAt}`);
|
||||
|
||||
sub('Summary');
|
||||
console.log(JSON.stringify(cached.summary, null, 2));
|
||||
|
||||
sub('LLM XML 报告 (llmReport)');
|
||||
console.log(cached.llmReport || '(空)');
|
||||
}
|
||||
|
||||
// ─── 主流程 ──────────────────────────────────────────────────
|
||||
async function main() {
|
||||
console.log('╔════════════════════════════════════════════════════════════════╗');
|
||||
console.log('║ IIT 质控全链路诊断脚本 (QC Pipeline Deep Test) ║');
|
||||
console.log('║ 项目: ' + PROJECT_ID.padEnd(47) + '║');
|
||||
console.log('╚════════════════════════════════════════════════════════════════╝');
|
||||
|
||||
try {
|
||||
// Part 1: REDCap 原始数据(可能因 REDCap 不可达而跳过)
|
||||
try {
|
||||
await part1_redcapRawData();
|
||||
} catch (e: any) {
|
||||
console.log(`\n ⚠️ Part 1 出错: ${e.message}(继续执行后续部分)`);
|
||||
}
|
||||
|
||||
// Part 2: 质控执行 + 数据库验证
|
||||
try {
|
||||
await part2_qcExecutionAndStorage();
|
||||
} catch (e: any) {
|
||||
console.log(`\n ⚠️ Part 2 出错: ${e.message}`);
|
||||
console.log(' 尝试仅验证数据库现有数据...\n');
|
||||
await part2_dbOnly();
|
||||
}
|
||||
|
||||
// Part 3: LLM 报告
|
||||
try {
|
||||
await part3_llmReport();
|
||||
} catch (e: any) {
|
||||
console.log(`\n ⚠️ Part 3 出错: ${e.message}`);
|
||||
console.log(' 尝试从数据库缓存读取报告...\n');
|
||||
await part3_dbOnly();
|
||||
}
|
||||
|
||||
section('✅ 全链路诊断完成');
|
||||
console.log(' 请检查以上输出确认数据正确性。');
|
||||
} catch (error: any) {
|
||||
console.error('\n❌ 诊断过程出错:', error.message);
|
||||
console.error(error.stack);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -111,19 +111,27 @@ import { userRoutes } from './modules/admin/routes/userRoutes.js';
|
||||
import { statsRoutes, userOverviewRoute } from './modules/admin/routes/statsRoutes.js';
|
||||
import { systemKbRoutes } from './modules/admin/system-kb/index.js';
|
||||
import { iitProjectRoutes, iitQcRuleRoutes, iitUserMappingRoutes, iitBatchRoutes, iitQcCockpitRoutes, iitEqueryRoutes } from './modules/admin/iit-projects/index.js';
|
||||
import { authenticate, requireRoles } from './common/auth/auth.middleware.js';
|
||||
await fastify.register(tenantRoutes, { prefix: '/api/admin/tenants' });
|
||||
await fastify.register(moduleRoutes, { prefix: '/api/admin/modules' });
|
||||
await fastify.register(userRoutes, { prefix: '/api/admin/users' });
|
||||
await fastify.register(statsRoutes, { prefix: '/api/admin/stats' });
|
||||
await fastify.register(userOverviewRoute, { prefix: '/api/admin/users' });
|
||||
await fastify.register(systemKbRoutes, { prefix: '/api/v1/admin/system-kb' });
|
||||
await fastify.register(iitProjectRoutes, { prefix: '/api/v1/admin/iit-projects' });
|
||||
await fastify.register(iitQcRuleRoutes, { prefix: '/api/v1/admin/iit-projects' });
|
||||
await fastify.register(iitUserMappingRoutes, { prefix: '/api/v1/admin/iit-projects' });
|
||||
await fastify.register(iitBatchRoutes, { prefix: '/api/v1/admin/iit-projects' }); // 一键全量质控/汇总
|
||||
await fastify.register(iitQcCockpitRoutes, { prefix: '/api/v1/admin/iit-projects' }); // 质控驾驶舱
|
||||
await fastify.register(iitEqueryRoutes, { prefix: '/api/v1/admin/iit-projects' }); // eQuery 闭环
|
||||
logger.info('✅ 运营管理路由已注册: /api/admin/tenants, /api/admin/modules, /api/admin/users, /api/admin/stats, /api/v1/admin/system-kb, /api/v1/admin/iit-projects');
|
||||
|
||||
// IIT 项目管理路由 — 认证 + 角色守卫(SUPER_ADMIN, PROMPT_ENGINEER, IIT_OPERATOR, PHARMA_ADMIN, HOSPITAL_ADMIN 可访问)
|
||||
await fastify.register(async (scope) => {
|
||||
scope.addHook('preHandler', authenticate);
|
||||
scope.addHook('preHandler', requireRoles('SUPER_ADMIN', 'PROMPT_ENGINEER', 'IIT_OPERATOR', 'PHARMA_ADMIN', 'HOSPITAL_ADMIN'));
|
||||
await scope.register(iitProjectRoutes);
|
||||
await scope.register(iitQcRuleRoutes);
|
||||
await scope.register(iitUserMappingRoutes);
|
||||
await scope.register(iitBatchRoutes);
|
||||
await scope.register(iitQcCockpitRoutes);
|
||||
await scope.register(iitEqueryRoutes);
|
||||
}, { prefix: '/api/v1/admin/iit-projects' });
|
||||
|
||||
logger.info('✅ 运营管理路由已注册: /api/admin/tenants, /api/admin/modules, /api/admin/users, /api/admin/stats, /api/v1/admin/system-kb, /api/v1/admin/iit-projects (authenticated)');
|
||||
|
||||
// ============================================
|
||||
// 【临时】平台基础设施测试API
|
||||
|
||||
@@ -67,79 +67,86 @@ export class IitBatchController {
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 统计(按 record+event 组合)
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
let warningCount = 0;
|
||||
let uncertainCount = 0;
|
||||
|
||||
const uniqueRecords = new Set<string>();
|
||||
// 3. V3.2: 按 record 级别聚合状态(每个 record 取最严重状态)
|
||||
const statusPriority: Record<string, number> = { 'FAIL': 3, 'WARNING': 2, 'UNCERTAIN': 1, 'PASS': 0 };
|
||||
const recordWorstStatus = new Map<string, string>();
|
||||
|
||||
for (const result of results) {
|
||||
uniqueRecords.add(result.recordId);
|
||||
|
||||
if (result.overallStatus === 'PASS') passCount++;
|
||||
else if (result.overallStatus === 'FAIL') failCount++;
|
||||
else if (result.overallStatus === 'WARNING') warningCount++;
|
||||
else uncertainCount++;
|
||||
|
||||
// 更新录入汇总表(取最差状态)
|
||||
const existingSummary = await prisma.iitRecordSummary.findUnique({
|
||||
where: { projectId_recordId: { projectId, recordId: result.recordId } }
|
||||
});
|
||||
|
||||
const statusPriority: Record<string, number> = { 'FAIL': 3, 'WARNING': 2, 'UNCERTAIN': 1, 'PASS': 0 };
|
||||
const currentPriority = statusPriority[result.overallStatus] || 0;
|
||||
const existingPriority = statusPriority[existingSummary?.latestQcStatus || 'PASS'] || 0;
|
||||
|
||||
// 只更新为更严重的状态
|
||||
if (!existingSummary || currentPriority > existingPriority) {
|
||||
await prisma.iitRecordSummary.upsert({
|
||||
where: { projectId_recordId: { projectId, recordId: result.recordId } },
|
||||
create: {
|
||||
projectId,
|
||||
recordId: result.recordId,
|
||||
lastUpdatedAt: new Date(),
|
||||
latestQcStatus: result.overallStatus,
|
||||
latestQcAt: new Date(),
|
||||
formStatus: {},
|
||||
updateCount: 1
|
||||
},
|
||||
update: {
|
||||
latestQcStatus: result.overallStatus,
|
||||
latestQcAt: new Date()
|
||||
}
|
||||
});
|
||||
const existing = recordWorstStatus.get(result.recordId);
|
||||
const currentPrio = statusPriority[result.overallStatus] ?? 0;
|
||||
const existingPrio = existing ? (statusPriority[existing] ?? 0) : -1;
|
||||
if (currentPrio > existingPrio) {
|
||||
recordWorstStatus.set(result.recordId, result.overallStatus);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 更新项目统计表
|
||||
// V3.2: 用本次批量质控结果更新 record_summary(覆盖旧状态)
|
||||
for (const [recordId, worstStatus] of recordWorstStatus) {
|
||||
await prisma.iitRecordSummary.upsert({
|
||||
where: { projectId_recordId: { projectId, recordId } },
|
||||
create: {
|
||||
projectId,
|
||||
recordId,
|
||||
lastUpdatedAt: new Date(),
|
||||
latestQcStatus: worstStatus,
|
||||
latestQcAt: new Date(),
|
||||
formStatus: {},
|
||||
updateCount: 1
|
||||
},
|
||||
update: {
|
||||
latestQcStatus: worstStatus,
|
||||
latestQcAt: new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// V3.2: 清理该项目旧版本日志(event_id 为 NULL 的遗留数据)
|
||||
const deletedLegacy = await prisma.iitQcLog.deleteMany({
|
||||
where: { projectId, eventId: null }
|
||||
});
|
||||
if (deletedLegacy.count > 0) {
|
||||
logger.info('🧹 清理旧版质控日志', { projectId, deletedCount: deletedLegacy.count });
|
||||
}
|
||||
|
||||
// V3.2: record 级别统计
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
let warningCount = 0;
|
||||
|
||||
for (const status of recordWorstStatus.values()) {
|
||||
if (status === 'PASS') passCount++;
|
||||
else if (status === 'FAIL') failCount++;
|
||||
else warningCount++;
|
||||
}
|
||||
|
||||
const totalRecords = recordWorstStatus.size;
|
||||
|
||||
// 4. 更新项目统计表(record 级别)
|
||||
await prisma.iitQcProjectStats.upsert({
|
||||
where: { projectId },
|
||||
create: {
|
||||
projectId,
|
||||
totalRecords: uniqueRecords.size,
|
||||
totalRecords,
|
||||
passedRecords: passCount,
|
||||
failedRecords: failCount,
|
||||
warningRecords: warningCount + uncertainCount
|
||||
warningRecords: warningCount
|
||||
},
|
||||
update: {
|
||||
totalRecords: uniqueRecords.size,
|
||||
totalRecords,
|
||||
passedRecords: passCount,
|
||||
failedRecords: failCount,
|
||||
warningRecords: warningCount + uncertainCount
|
||||
warningRecords: warningCount
|
||||
}
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
logger.info('✅ 事件级全量质控完成', {
|
||||
projectId,
|
||||
uniqueRecords: uniqueRecords.size,
|
||||
totalRecords,
|
||||
totalEventCombinations: results.length,
|
||||
passCount,
|
||||
failCount,
|
||||
warningCount,
|
||||
uncertainCount,
|
||||
durationMs
|
||||
});
|
||||
|
||||
@@ -147,13 +154,14 @@ export class IitBatchController {
|
||||
success: true,
|
||||
message: '事件级全量质控完成',
|
||||
stats: {
|
||||
totalRecords: uniqueRecords.size,
|
||||
totalRecords,
|
||||
totalEventCombinations: results.length,
|
||||
passed: passCount,
|
||||
failed: failCount,
|
||||
warnings: warningCount,
|
||||
uncertain: uncertainCount,
|
||||
passRate: `${((passCount / results.length) * 100).toFixed(1)}%`
|
||||
passRate: totalRecords > 0
|
||||
? `${((passCount / totalRecords) * 100).toFixed(1)}%`
|
||||
: '0%'
|
||||
},
|
||||
durationMs
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ interface ProjectIdParams {
|
||||
interface ListProjectsQuery {
|
||||
status?: string;
|
||||
search?: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
interface TestConnectionBody {
|
||||
@@ -29,6 +30,27 @@ interface LinkKbBody {
|
||||
|
||||
// ==================== 控制器函数 ====================
|
||||
|
||||
/**
|
||||
* 获取租户选项列表(供创建项目时选择租户)
|
||||
*/
|
||||
export async function listTenantOptions(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const tenants = await prisma.tenants.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
select: { id: true, code: true, name: true, type: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return reply.send({ success: true, data: tenants });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
logger.error('获取租户列表失败', { error: msg });
|
||||
return reply.status(500).send({ success: false, error: msg });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目列表
|
||||
*/
|
||||
@@ -37,9 +59,17 @@ export async function listProjects(
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { status, search } = request.query;
|
||||
const { status, search, tenantId: queryTenantId } = request.query;
|
||||
const user = request.user;
|
||||
const service = getIitProjectService(prisma);
|
||||
const projects = await service.listProjects({ status, search });
|
||||
|
||||
// Phase 3 租户隔离:非 SUPER_ADMIN/IIT_OPERATOR 只能看自己租户的项目
|
||||
let effectiveTenantId = queryTenantId;
|
||||
if (user && user.role !== 'SUPER_ADMIN' && user.role !== 'IIT_OPERATOR') {
|
||||
effectiveTenantId = user.tenantId;
|
||||
}
|
||||
|
||||
const projects = await service.listProjects({ status, search, tenantId: effectiveTenantId });
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
@@ -92,11 +122,12 @@ export async function getProject(
|
||||
* 创建项目
|
||||
*/
|
||||
export async function createProject(
|
||||
request: FastifyRequest<{ Body: CreateProjectInput }>,
|
||||
request: FastifyRequest<{ Body: CreateProjectInput & { tenantId?: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const input = request.body;
|
||||
const user = request.user;
|
||||
|
||||
// 验证必填字段
|
||||
if (!input.name) {
|
||||
@@ -113,6 +144,17 @@ export async function createProject(
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 3: 自动绑定 tenantId
|
||||
// SUPER_ADMIN/IIT_OPERATOR: 可以指定 tenantId(创建时选择为哪个客户创建)
|
||||
// PHARMA_ADMIN/HOSPITAL_ADMIN: 自动绑定自己的 tenantId
|
||||
if (user) {
|
||||
if (user.role === 'SUPER_ADMIN' || user.role === 'IIT_OPERATOR') {
|
||||
// 可以使用请求体中指定的 tenantId,或不指定
|
||||
} else {
|
||||
input.tenantId = user.tenantId;
|
||||
}
|
||||
}
|
||||
|
||||
const service = getIitProjectService(prisma);
|
||||
const project = await service.createProject(input);
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ import { FastifyInstance } from 'fastify';
|
||||
import * as controller from './iitProjectController.js';
|
||||
|
||||
export async function iitProjectRoutes(fastify: FastifyInstance) {
|
||||
// ==================== 租户选项(创建项目时选择) ====================
|
||||
fastify.get('/tenant-options', controller.listTenantOptions);
|
||||
|
||||
// ==================== 项目 CRUD ====================
|
||||
|
||||
// 获取项目列表
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface CreateProjectInput {
|
||||
redcapApiToken: string;
|
||||
fieldMappings?: Record<string, unknown>;
|
||||
knowledgeBaseId?: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProjectInput {
|
||||
@@ -28,6 +29,7 @@ export interface UpdateProjectInput {
|
||||
fieldMappings?: Record<string, unknown>;
|
||||
knowledgeBaseId?: string;
|
||||
status?: string;
|
||||
isDemo?: boolean;
|
||||
}
|
||||
|
||||
export interface TestConnectionResult {
|
||||
@@ -41,6 +43,7 @@ export interface TestConnectionResult {
|
||||
export interface ProjectListFilters {
|
||||
status?: string;
|
||||
search?: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
// ==================== 服务实现 ====================
|
||||
@@ -60,6 +63,10 @@ export class IitProjectService {
|
||||
where.status = filters.status;
|
||||
}
|
||||
|
||||
if (filters?.tenantId) {
|
||||
where.tenantId = filters.tenantId;
|
||||
}
|
||||
|
||||
if (filters?.search) {
|
||||
where.OR = [
|
||||
{ name: { contains: filters.search, mode: 'insensitive' } },
|
||||
@@ -77,10 +84,14 @@ export class IitProjectService {
|
||||
redcapProjectId: true,
|
||||
redcapUrl: true,
|
||||
knowledgeBaseId: true,
|
||||
tenantId: true,
|
||||
status: true,
|
||||
lastSyncAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
tenant: {
|
||||
select: { id: true, name: true, code: true },
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
userMappings: true,
|
||||
@@ -91,6 +102,8 @@ export class IitProjectService {
|
||||
|
||||
return projects.map((p) => ({
|
||||
...p,
|
||||
tenantName: p.tenant?.name || null,
|
||||
tenantCode: p.tenant?.code || null,
|
||||
userMappingCount: p._count.userMappings,
|
||||
}));
|
||||
}
|
||||
@@ -102,6 +115,7 @@ export class IitProjectService {
|
||||
const project = await this.prisma.iitProject.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
include: {
|
||||
tenant: { select: { id: true, name: true, code: true } },
|
||||
userMappings: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -173,8 +187,9 @@ export class IitProjectService {
|
||||
redcapUrl: input.redcapUrl,
|
||||
redcapProjectId: input.redcapProjectId,
|
||||
redcapApiToken: input.redcapApiToken,
|
||||
fieldMappings: input.fieldMappings || {},
|
||||
fieldMappings: (input.fieldMappings || {}) as any,
|
||||
knowledgeBaseId: input.knowledgeBaseId,
|
||||
tenantId: input.tenantId || null,
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
@@ -207,9 +222,10 @@ export class IitProjectService {
|
||||
redcapUrl: input.redcapUrl,
|
||||
redcapProjectId: input.redcapProjectId,
|
||||
redcapApiToken: input.redcapApiToken,
|
||||
fieldMappings: input.fieldMappings,
|
||||
fieldMappings: input.fieldMappings as any,
|
||||
knowledgeBaseId: input.knowledgeBaseId,
|
||||
status: input.status,
|
||||
isDemo: input.isDemo,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -97,23 +97,21 @@ export async function createUserMapping(
|
||||
const { projectId } = request.params;
|
||||
const input = request.body;
|
||||
|
||||
// 验证必填字段 - 只有企业微信用户 ID 是必填的
|
||||
if (!input.wecomUserId) {
|
||||
// 验证:至少提供 userId(平台用户)或 wecomUserId(企业微信用户)
|
||||
if (!input.userId && !input.wecomUserId) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: '请输入企业微信用户 ID',
|
||||
error: '请选择平台用户或输入企业微信用户 ID',
|
||||
});
|
||||
}
|
||||
|
||||
// 如果没有提供 systemUserId,使用 wecomUserId 作为默认值
|
||||
const fallbackId = input.wecomUserId || input.userId || 'unknown';
|
||||
if (!input.systemUserId) {
|
||||
input.systemUserId = input.wecomUserId;
|
||||
input.systemUserId = fallbackId;
|
||||
}
|
||||
// 如果没有提供 redcapUsername,使用 wecomUserId 作为默认值
|
||||
if (!input.redcapUsername) {
|
||||
input.redcapUsername = input.wecomUserId;
|
||||
input.redcapUsername = fallbackId;
|
||||
}
|
||||
// 如果没有提供 role,默认为 PI
|
||||
if (!input.role) {
|
||||
input.role = 'PI';
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface CreateUserMappingInput {
|
||||
systemUserId: string;
|
||||
redcapUsername: string;
|
||||
wecomUserId?: string;
|
||||
userId?: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@@ -19,6 +20,7 @@ export interface UpdateUserMappingInput {
|
||||
systemUserId?: string;
|
||||
redcapUsername?: string;
|
||||
wecomUserId?: string;
|
||||
userId?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
@@ -54,6 +56,9 @@ export class IitUserMappingService {
|
||||
|
||||
const mappings = await this.prisma.iitUserMapping.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: { select: { id: true, name: true, phone: true, email: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
@@ -106,6 +111,7 @@ export class IitUserMappingService {
|
||||
systemUserId: input.systemUserId,
|
||||
redcapUsername: input.redcapUsername,
|
||||
wecomUserId: input.wecomUserId,
|
||||
userId: input.userId || null,
|
||||
role: input.role,
|
||||
},
|
||||
});
|
||||
@@ -132,6 +138,7 @@ export class IitUserMappingService {
|
||||
systemUserId: input.systemUserId,
|
||||
redcapUsername: input.redcapUsername,
|
||||
wecomUserId: input.wecomUserId,
|
||||
userId: input.userId,
|
||||
role: input.role,
|
||||
},
|
||||
});
|
||||
@@ -164,6 +171,7 @@ export class IitUserMappingService {
|
||||
*/
|
||||
getRoleOptions() {
|
||||
return [
|
||||
{ value: 'PM', label: '项目管理员 (PM)' },
|
||||
{ value: 'PI', label: '主要研究者 (PI)' },
|
||||
{ value: 'Sub-I', label: '次要研究者 (Sub-I)' },
|
||||
{ value: 'CRC', label: '临床研究协调员 (CRC)' },
|
||||
|
||||
@@ -50,7 +50,8 @@ export async function getUserQueryScope(
|
||||
switch (userRole) {
|
||||
case 'SUPER_ADMIN':
|
||||
case 'PROMPT_ENGINEER':
|
||||
return {}; // 无限制
|
||||
case 'IIT_OPERATOR':
|
||||
return {}; // 无限制(IIT 项目跨机构协作,需搜索所有租户用户)
|
||||
case 'HOSPITAL_ADMIN':
|
||||
case 'PHARMA_ADMIN':
|
||||
return { tenantId }; // 只能查看本租户
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
import { authenticate, requireRoles } from '../../../common/auth/auth.middleware.js';
|
||||
|
||||
export async function systemKbRoutes(fastify: FastifyInstance) {
|
||||
// 所有路由都需要认证 + SUPER_ADMIN 或 ADMIN 角色
|
||||
const preHandler = [authenticate, requireRoles('SUPER_ADMIN', 'ADMIN')];
|
||||
// 认证 + 角色守卫:SUPER_ADMIN / ADMIN / IIT_OPERATOR 均可完整操作知识库
|
||||
const preHandler = [authenticate, requireRoles('SUPER_ADMIN', 'ADMIN', 'IIT_OPERATOR')];
|
||||
|
||||
// ==================== 知识库 CRUD ====================
|
||||
|
||||
|
||||
@@ -298,7 +298,7 @@ export class SystemKbService {
|
||||
|
||||
try {
|
||||
// 3. 生成 OSS 存储路径并上传
|
||||
const ossKey = this.generateOssKey(kbId, doc.id, filename);
|
||||
const ossKey = this.generateOssKey(kbId, doc.id, filename, kb.category);
|
||||
const ossUrl = await storage.upload(ossKey, fileBuffer);
|
||||
|
||||
// 4. 更新 file_path
|
||||
@@ -473,15 +473,15 @@ export class SystemKbService {
|
||||
/**
|
||||
* 生成 OSS 存储路径
|
||||
*
|
||||
* 格式:system/knowledge-bases/{kbId}/{docId}.{ext}
|
||||
*
|
||||
* @param kbId - 知识库 ID
|
||||
* @param docId - 文档 ID
|
||||
* @param filename - 原始文件名(用于获取扩展名)
|
||||
* 系统知识库: system/knowledge-bases/{kbId}/{docId}.{ext}
|
||||
* IIT 项目知识库: system/iit-knowledge-bases/{kbId}/{docId}.{ext}
|
||||
*/
|
||||
private generateOssKey(kbId: string, docId: string, filename: string): string {
|
||||
private generateOssKey(kbId: string, docId: string, filename: string, category?: string | null): string {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
return `system/knowledge-bases/${kbId}/${docId}${ext}`;
|
||||
const prefix = category === 'iit_project'
|
||||
? 'system/iit-knowledge-bases'
|
||||
: 'system/knowledge-bases';
|
||||
return `${prefix}/${kbId}/${docId}${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface RuleResult {
|
||||
ruleName: string;
|
||||
field: string | string[];
|
||||
passed: boolean;
|
||||
skipped?: boolean; // V3.2: 字段缺失时标记为跳过
|
||||
message: string; // 基础消息
|
||||
llmMessage?: string; // V2.1: LLM 友好的自包含消息
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
@@ -231,29 +232,50 @@ export class HardRuleEngine {
|
||||
return records.map(r => this.execute(r.recordId, r.data));
|
||||
}
|
||||
|
||||
/**
|
||||
* V3.2: 检查规则所需字段是否在数据中可用
|
||||
*
|
||||
* 当所有字段均为 null/undefined/空字符串时返回 false
|
||||
*/
|
||||
private isFieldAvailable(field: string | string[], data: Record<string, any>): boolean {
|
||||
const fields = Array.isArray(field) ? field : [field];
|
||||
return fields.some(f => {
|
||||
const val = data[f];
|
||||
return val !== undefined && val !== null && val !== '';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行单条规则
|
||||
*
|
||||
* V2.1 优化:生成自包含的 LLM 友好消息
|
||||
* V3.2: 字段缺失时标记为 SKIP 而非 FAIL
|
||||
*/
|
||||
private executeRule(rule: QCRule, data: Record<string, any>): RuleResult {
|
||||
try {
|
||||
// 获取字段值
|
||||
const fieldValue = this.getFieldValue(rule.field, data);
|
||||
|
||||
// 执行 JSON Logic
|
||||
const passed = jsonLogic.apply(rule.logic, data) as boolean;
|
||||
// V3.2: 字段缺失预检查 - 缺失时跳过而非判定失败
|
||||
if (!this.isFieldAvailable(rule.field, data)) {
|
||||
return {
|
||||
ruleId: rule.id,
|
||||
ruleName: rule.name,
|
||||
field: rule.field,
|
||||
passed: true,
|
||||
skipped: true,
|
||||
message: '字段缺失,跳过检查',
|
||||
severity: rule.severity,
|
||||
category: rule.category,
|
||||
actualValue: fieldValue,
|
||||
};
|
||||
}
|
||||
|
||||
// V2.1: 解析期望值(从 JSON Logic 中提取)
|
||||
const passed = jsonLogic.apply(rule.logic, data) as boolean;
|
||||
const expectedValue = this.extractExpectedValue(rule.logic);
|
||||
const expectedCondition = this.describeLogic(rule.logic);
|
||||
|
||||
// V2.1: 构建自包含的 LLM 友好消息
|
||||
const llmMessage = passed
|
||||
? '通过'
|
||||
: this.buildLlmMessage(rule, fieldValue, expectedValue);
|
||||
|
||||
// V2.1: 构建结构化证据
|
||||
const evidence = {
|
||||
value: fieldValue,
|
||||
threshold: expectedValue,
|
||||
|
||||
@@ -273,8 +273,9 @@ export class SkillRunner {
|
||||
/**
|
||||
* 获取要处理的记录(事件级别)
|
||||
*
|
||||
* V3.1: 返回事件级数据,每个 record+event 作为独立单元
|
||||
* 不再合并事件数据,确保每个访视独立质控
|
||||
* V3.2: 将每个 record 的第一个 event(筛选/基线)数据
|
||||
* 合并到后续 event 中,确保纳入/排除规则的字段在所有事件可用。
|
||||
* 后续 event 自身的字段值优先(覆盖基线值)。
|
||||
*/
|
||||
private async getRecordsToProcess(
|
||||
options?: SkillRunnerOptions
|
||||
@@ -287,19 +288,54 @@ export class SkillRunner {
|
||||
}>> {
|
||||
const adapter = await this.initRedcapAdapter();
|
||||
|
||||
// V3.1: 使用 getAllRecordsByEvent 获取事件级数据
|
||||
const eventRecords = await adapter.getAllRecordsByEvent({
|
||||
recordId: options?.recordId,
|
||||
eventName: options?.eventName,
|
||||
});
|
||||
|
||||
return eventRecords.map(r => ({
|
||||
recordId: r.recordId,
|
||||
eventName: r.eventName,
|
||||
eventLabel: r.eventLabel,
|
||||
forms: r.forms,
|
||||
data: r.data,
|
||||
}));
|
||||
// V3.2: 按 recordId 分组,找到每个 record 的第一个 event 作为基线
|
||||
const recordGroups = new Map<string, typeof eventRecords>();
|
||||
for (const r of eventRecords) {
|
||||
if (!recordGroups.has(r.recordId)) {
|
||||
recordGroups.set(r.recordId, []);
|
||||
}
|
||||
recordGroups.get(r.recordId)!.push(r);
|
||||
}
|
||||
|
||||
const results: Array<{
|
||||
recordId: string;
|
||||
eventName: string;
|
||||
eventLabel: string;
|
||||
forms: string[];
|
||||
data: Record<string, any>;
|
||||
}> = [];
|
||||
|
||||
for (const [recordId, events] of recordGroups) {
|
||||
const baselineData = events[0]?.data || {};
|
||||
|
||||
for (const event of events) {
|
||||
// 基线字段作为底层,当前事件字段覆盖(当前事件有值的字段优先)
|
||||
const mergedData: Record<string, any> = { ...baselineData };
|
||||
for (const [key, val] of Object.entries(event.data)) {
|
||||
if (val !== undefined && val !== null && val !== '') {
|
||||
mergedData[key] = val;
|
||||
}
|
||||
}
|
||||
// 保持 REDCap 元字段为当前事件的值
|
||||
mergedData.redcap_event_name = event.data.redcap_event_name;
|
||||
mergedData.record_id = event.data.record_id;
|
||||
|
||||
results.push({
|
||||
recordId: event.recordId,
|
||||
eventName: event.eventName,
|
||||
eventLabel: event.eventLabel,
|
||||
forms: event.forms,
|
||||
data: mergedData,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -542,6 +578,17 @@ export class SkillRunner {
|
||||
*
|
||||
* V2.1 优化:添加 expectedValue, llmMessage, evidence 字段
|
||||
*/
|
||||
/**
|
||||
* V3.2: 检查规则所需字段是否在数据中可用
|
||||
*/
|
||||
private isFieldAvailable(field: string | string[], data: Record<string, any>): boolean {
|
||||
const fields = Array.isArray(field) ? field : [field];
|
||||
return fields.some(f => {
|
||||
const val = data[f];
|
||||
return val !== undefined && val !== null && val !== '';
|
||||
});
|
||||
}
|
||||
|
||||
private executeHardRulesDirectly(
|
||||
rules: QCRule[],
|
||||
recordId: string,
|
||||
@@ -553,16 +600,22 @@ export class SkillRunner {
|
||||
|
||||
for (const rule of rules) {
|
||||
try {
|
||||
// V3.2: 字段缺失预检查 - 缺失时跳过而非判定失败
|
||||
if (!this.isFieldAvailable(rule.field, data)) {
|
||||
logger.debug('[SkillRunner] Skipping rule - field not available', {
|
||||
ruleId: rule.id,
|
||||
field: rule.field,
|
||||
recordId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const passed = jsonLogic.apply(rule.logic, data);
|
||||
|
||||
if (!passed) {
|
||||
const severity = rule.severity === 'error' ? 'critical' : 'warning';
|
||||
const actualValue = this.getFieldValue(rule.field, data);
|
||||
|
||||
// V2.1: 提取期望值
|
||||
const expectedValue = this.extractExpectedValue(rule.logic);
|
||||
|
||||
// V2.1: 构建自包含的 LLM 友好消息
|
||||
const llmMessage = this.buildLlmMessage(rule, actualValue, expectedValue);
|
||||
|
||||
issues.push({
|
||||
@@ -570,11 +623,11 @@ export class SkillRunner {
|
||||
ruleName: rule.name,
|
||||
field: rule.field,
|
||||
message: rule.message,
|
||||
llmMessage, // V2.1: 自包含消息
|
||||
llmMessage,
|
||||
severity,
|
||||
actualValue,
|
||||
expectedValue, // V2.1: 期望值
|
||||
evidence: { // V2.1: 结构化证据
|
||||
expectedValue,
|
||||
evidence: {
|
||||
value: actualValue,
|
||||
threshold: expectedValue,
|
||||
unit: (rule.metadata as any)?.unit,
|
||||
|
||||
@@ -468,6 +468,39 @@ export async function registerIitRoutes(fastify: FastifyInstance) {
|
||||
// =============================================
|
||||
const { getChatOrchestrator } = await import('../services/ChatOrchestrator.js');
|
||||
|
||||
/** 路由层兜底:过滤 LLM 泄漏的 DSML/XML 工具调用标签 */
|
||||
function sanitizeLlmReply(text: string): string {
|
||||
// 策略1:关键词检测 + 截断(最可靠)
|
||||
// 如果文本包含 "DSML" 关键词,截取到第一个 DSML 出现之前的内容
|
||||
if (text.includes('DSML')) {
|
||||
// 找到包含 DSML 的第一个 < 符号位置
|
||||
const dsmlIdx = text.indexOf('DSML');
|
||||
// 向前搜索最近的 < 符号
|
||||
let cutStart = text.lastIndexOf('<', dsmlIdx);
|
||||
if (cutStart === -1) cutStart = dsmlIdx;
|
||||
const before = text.substring(0, cutStart).trim();
|
||||
// 尝试找到最后一个 > 后的文本(DSML 块之后可能还有正常内容)
|
||||
const lastClose = text.lastIndexOf('>');
|
||||
const after = lastClose > dsmlIdx ? text.substring(lastClose + 1).trim() : '';
|
||||
const result = (before + (after ? '\n' + after : '')).trim();
|
||||
logger.info('[sanitizeLlmReply] Stripped DSML via keyword detection', {
|
||||
originalLen: text.length,
|
||||
cleanLen: result.length,
|
||||
cutStart,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// 策略2:正则兜底(处理非 DSML 格式的工具调用标签)
|
||||
let cleaned = text;
|
||||
cleaned = cleaned.replace(/<\s*\/?\s*function_calls?\s*>[\s\S]*?<\s*\/\s*function_calls?\s*>/gi, '');
|
||||
cleaned = cleaned.replace(/<\s*\/?\s*function_calls?\s*>/gi, '');
|
||||
cleaned = cleaned.replace(/<\s*\/?\s*invoke\s*[^>]*>/gi, '');
|
||||
cleaned = cleaned.replace(/<\s*\/?\s*parameter\s*[^>]*>/gi, '');
|
||||
cleaned = cleaned.replace(/<\s*\/?\s*tool_call\s*[^>]*>/gi, '');
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
fastify.post(
|
||||
'/api/v1/iit/chat',
|
||||
{
|
||||
@@ -480,15 +513,6 @@ export async function registerIitRoutes(fastify: FastifyInstance) {
|
||||
userId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
reply: { type: 'string' },
|
||||
duration: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request: any, reply) => {
|
||||
@@ -497,15 +521,22 @@ export async function registerIitRoutes(fastify: FastifyInstance) {
|
||||
const { message, userId } = request.body;
|
||||
const uid = userId || request.user?.id || 'web-user';
|
||||
const orchestrator = await getChatOrchestrator();
|
||||
const replyText = await orchestrator.handleMessage(uid, message);
|
||||
const rawReply = await orchestrator.handleMessage(uid, message);
|
||||
const cleanReply = sanitizeLlmReply(rawReply);
|
||||
|
||||
logger.info('[WebChat] Reply sanitized', {
|
||||
hadDsml: rawReply !== cleanReply,
|
||||
rawLen: rawReply.length,
|
||||
cleanLen: cleanReply.length,
|
||||
});
|
||||
|
||||
return reply.code(200).send({
|
||||
reply: replyText,
|
||||
reply: cleanReply || '抱歉,我暂时无法回答这个问题。',
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('Web chat failed', { error: error.message });
|
||||
return reply.code(500).send({
|
||||
return (reply as any).code(500).send({
|
||||
reply: '系统处理出错,请稍后重试。',
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
@@ -514,6 +545,75 @@ export async function registerIitRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
logger.info('Registered route: POST /api/v1/iit/chat');
|
||||
|
||||
// =============================================
|
||||
// My Projects API(当前用户关联的 IIT 项目)
|
||||
// =============================================
|
||||
const { authenticate } = await import('../../../common/auth/auth.middleware.js');
|
||||
const { PrismaClient } = await import('@prisma/client');
|
||||
const prismaForMyProjects = new PrismaClient();
|
||||
|
||||
fastify.get(
|
||||
'/api/v1/iit/my-projects',
|
||||
{ preHandler: [authenticate] },
|
||||
async (request: any, reply) => {
|
||||
try {
|
||||
const userId = request.user?.userId;
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const mappings = await prismaForMyProjects.iitUserMapping.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
status: true,
|
||||
redcapProjectId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const projects = mappings
|
||||
.filter(m => m.project.status === 'active' && !('deletedAt' in m.project && (m.project as any).deletedAt))
|
||||
.map(m => ({
|
||||
id: m.project.id,
|
||||
name: m.project.name,
|
||||
description: m.project.description,
|
||||
status: m.project.status,
|
||||
redcapProjectId: m.project.redcapProjectId,
|
||||
createdAt: m.project.createdAt,
|
||||
myRole: m.role,
|
||||
isDemo: false,
|
||||
}));
|
||||
|
||||
if (projects.length === 0) {
|
||||
const demoProjects = await prismaForMyProjects.iitProject.findMany({
|
||||
where: { isDemo: true, status: 'active', deletedAt: null },
|
||||
select: {
|
||||
id: true, name: true, description: true, status: true,
|
||||
redcapProjectId: true, createdAt: true, isDemo: true,
|
||||
},
|
||||
});
|
||||
const demoList = demoProjects.map(p => ({
|
||||
...p, myRole: 'VIEWER', isDemo: true,
|
||||
}));
|
||||
return reply.code(200).send({ success: true, data: demoList });
|
||||
}
|
||||
|
||||
return reply.code(200).send({ success: true, data: projects });
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get my projects', { error: error.message });
|
||||
return reply.code(500).send({ success: false, error: '获取项目列表失败' });
|
||||
}
|
||||
}
|
||||
);
|
||||
logger.info('Registered route: GET /api/v1/iit/my-projects');
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,30 @@ const prisma = new PrismaClient();
|
||||
const MAX_ROUNDS = 3;
|
||||
const DEFAULT_MODEL = 'deepseek-v3' as const;
|
||||
|
||||
/**
|
||||
* 过滤 LLM 输出中泄漏的 DSML / XML 工具调用标签。
|
||||
* DeepSeek 有时会在 content 中混入 DSML function_calls 等标记。
|
||||
* 使用关键词检测而非纯正则,因为 LLM 输出可能含有不可见 Unicode 字符。
|
||||
*/
|
||||
function stripToolCallXml(text: string): string {
|
||||
if (text.includes('DSML')) {
|
||||
const dsmlIdx = text.indexOf('DSML');
|
||||
let cutStart = text.lastIndexOf('<', dsmlIdx);
|
||||
if (cutStart === -1) cutStart = dsmlIdx;
|
||||
const before = text.substring(0, cutStart).trim();
|
||||
const lastClose = text.lastIndexOf('>');
|
||||
const after = lastClose > dsmlIdx ? text.substring(lastClose + 1).trim() : '';
|
||||
return (before + (after ? '\n' + after : '')).trim();
|
||||
}
|
||||
if (text.includes('function_calls')) {
|
||||
const idx = text.indexOf('function_calls');
|
||||
let cutStart = text.lastIndexOf('<', idx);
|
||||
if (cutStart === -1) cutStart = idx;
|
||||
return text.substring(0, cutStart).trim();
|
||||
}
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `You are a CRA Agent (Clinical Research Associate AI) monitoring an IIT clinical study.
|
||||
Your users are PIs (principal investigators) and research coordinators.
|
||||
|
||||
@@ -93,15 +117,15 @@ export class ChatOrchestrator {
|
||||
});
|
||||
|
||||
if (!response.toolCalls?.length || response.finishReason === 'stop') {
|
||||
const answer = response.content || '抱歉,我暂时无法回答这个问题。';
|
||||
const answer = stripToolCallXml(response.content || '') || '抱歉,我暂时无法回答这个问题。';
|
||||
this.saveConversation(userId, userMessage, answer, startTime);
|
||||
return answer;
|
||||
}
|
||||
|
||||
// Append assistant message with tool_calls
|
||||
// Append assistant message with tool_calls (strip leaked XML from content)
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: response.content,
|
||||
content: stripToolCallXml(response.content || ''),
|
||||
tool_calls: response.toolCalls,
|
||||
});
|
||||
|
||||
@@ -127,7 +151,7 @@ export class ChatOrchestrator {
|
||||
maxTokens: 1000,
|
||||
});
|
||||
|
||||
const answer = finalResponse.content || '抱歉,处理超时,请简化问题后重试。';
|
||||
const answer = stripToolCallXml(finalResponse.content || '') || '抱歉,处理超时,请简化问题后重试。';
|
||||
this.saveConversation(userId, userMessage, answer, startTime);
|
||||
return answer;
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -361,9 +361,19 @@ class QcReportServiceClass {
|
||||
const criticalIssues = seenCritical.size;
|
||||
const warningIssues = seenWarning.size;
|
||||
|
||||
// 计算通过率
|
||||
const passedRecords = latestQcLogs.filter(log =>
|
||||
log.status === 'PASS' || log.status === 'GREEN'
|
||||
// V3.2: 按 record 级别计算通过率(每个 record 取最严重状态)
|
||||
const statusPriority: Record<string, number> = { 'FAIL': 3, 'WARNING': 2, 'UNCERTAIN': 1, 'PASS': 0, 'GREEN': 0 };
|
||||
const recordWorstStatus = new Map<string, string>();
|
||||
for (const log of latestQcLogs) {
|
||||
const existing = recordWorstStatus.get(log.record_id);
|
||||
const currentPrio = statusPriority[log.status] ?? 0;
|
||||
const existingPrio = existing ? (statusPriority[existing] ?? 0) : -1;
|
||||
if (currentPrio > existingPrio) {
|
||||
recordWorstStatus.set(log.record_id, log.status);
|
||||
}
|
||||
}
|
||||
const passedRecords = [...recordWorstStatus.values()].filter(
|
||||
s => s === 'PASS' || s === 'GREEN'
|
||||
).length;
|
||||
const passRate = totalRecords > 0
|
||||
? Math.round((passedRecords / totalRecords) * 100 * 10) / 10
|
||||
|
||||
@@ -89,7 +89,7 @@ html, body { width: 100%; height: 100%; overflow: hidden;
|
||||
|
||||
function showGrantButton() {
|
||||
document.querySelector('#status .spinner').style.display = 'none';
|
||||
document.getElementById('msg').textContent = '需要授权以访问旧系统';
|
||||
document.getElementById('msg').textContent = '需要浏览器授权以继续访问';
|
||||
var btn = document.getElementById('btn');
|
||||
btn.style.display = 'inline-block';
|
||||
btn.onclick = function() {
|
||||
|
||||
264
backend/test-output/Herrschaft 2012.pdf.md
Normal file
264
backend/test-output/Herrschaft 2012.pdf.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# Ginkgo biloba extract EGb $7 6 1 ^ { \textregistered }$ in dementia with neuropsychiatric features: A randomised, placebo-controlled trial to confirm the efficacy and safety of a daily dose of 240 mg
|
||||
|
||||
Horst Herrschaft a,1 , Anatol Nacu b,1 , Sergey Likhachev c,1 , Ilya Sholomov d,1 , Robert Hoerr e,*,1 , Sandra Schlaefke e,1
|
||||
|
||||
a Medical Faculty, University of Cologne, Cologne, Germany
|
||||
b Faculty of Psychiatry, State Medical and Pharmaceutical University “N. Testemitianu”, Chis¸ inau, Republic of Moldova -
|
||||
c Department of Neurology, Republican Research and Application Center of Neurology and Neurosurgery, Minsk, Belarus
|
||||
d Faculty of Neurology, Saratov State Medical University of Federal Agency of Healthcare and Social Development, Saratov, Russian Federation
|
||||
e Clinical Research Department, Dr. Willmar Schwabe GmbH & Co. KG Pharmaceuticals, Willmar-Schwabe-Str. 4, 76227 Karlsruhe, Germany
|
||||
|
||||
# a r t i c l e i n f o
|
||||
|
||||
Article history:
|
||||
|
||||
Received 22 November 2011
|
||||
|
||||
Received in revised form
|
||||
|
||||
3 February 2012
|
||||
|
||||
Accepted 2 March 2012
|
||||
|
||||
Keywords:
|
||||
|
||||
Dementia
|
||||
|
||||
Alzheimer disease
|
||||
|
||||
Vascular dementia
|
||||
|
||||
Neuropsychiatric symptoms
|
||||
|
||||
Ginkgo biloba
|
||||
|
||||
EGb 761
|
||||
|
||||
Randomised controlled trial
|
||||
|
||||
# a b s t r a c t
|
||||
|
||||
A multi-centre, double-blind, randomised, placebo-controlled, 24-week trial with 410 outpatients was conducted to demonstrate efficacy and safety of a $2 4 0 \mathrm { m g }$ once-daily formulation of Ginkgo biloba extract EGb $7 6 1 ^ { \textregistered }$ in patients with mild to moderate dementia (Alzheimer’s disease or vascular dementia) associated with neuropsychiatric symptoms. Patients scored 9 to 23 on the SKT cognitive battery, at least 6 on the Neuropsychiatric Inventory (NPI), with at least one of four key items rated at least 4. Primary outcomes were the changes from baseline to week 24 in the SKT and NPI total scores. The ADCS Clinical Global Impression of Change (ADCS-CGIC), Verbal Fluency Test, Activities of Daily Living International Scale (ADL-IS), DEMQOL-Proxy quality-of-life scale and 11-point box scales for tinnitus and dizziness were secondary outcome measures. Patients treated with EGb $7 6 1 ^ { \textregistered }$ $n = 2 0 0 ^ { \cdot }$ improved by $2 . 2 \pm 3 . 5$ points (mean $\pm \ s \mathsf { d }$ ) on the SKT total score, whereas those receiving placebo ${ \mathit { n } } = 2 0 2 { \mathit { \Omega } }$ ) changed only slightly by $0 . 3 \pm 3 . 7$ points. The NPI composite score improved by $4 . 6 \pm 7 . 1$ in the EGb $7 6 1 ^ { \textregistered }$ -treated group and by $2 . 1 \pm 6 . 5$ in the placebo group. Both drug-placebo comparisons were significant at $p < 0 . 0 0 1$ . Patients treated with EGb $7 6 1 ^ { \textregistered }$ also showed a more favourable course in most of the secondary efficacy variables. In conclusion, treatment with EGb $7 6 1 ^ { \textregistered }$ at a once-daily dose of 240 mg was safe and resulted in a significant and clinically relevant improvement in cognition, psychopathology, functional measures and quality of life of patients and caregivers.
|
||||
|
||||
$©$ 2012 Elsevier Ltd. All rights reserved.
|
||||
|
||||
# 1. Objectives and background
|
||||
|
||||
The ageing of populations and the resulting increase in people’s risk for both Alzheimer’s disease (AD) and vascular dementia (VaD) has significant implications worldwide. The forecast indicates a considerable increase in the number of demented elderly from 25 million in the year 2000 to 63 million in 2030 (Wimo et al., 2003). In spite of the urgent need for treatments (Lindesay et al., 2010), extensive research and enormous investments in research, it still remains unclear what causes AD (Daviglus et al., 2010). This seriously hampers the
|
||||
|
||||
development of curative new drugs and has led to the failure of recently tested, putatively causal treatments (Holmes et al., 2008; Green et al., 2009). In the absence of causal treatments for AD, and faced with fragmentary knowledge about AD pathogenesis together with evidence of multiple and common risk factors for AD and VaD (Daviglus et al., 2010; Förstl, 2003; Newman et al., 2005) as well as a large proportion of dementias with mixed pathologies (Schneider et al., 2007), substances that interfere with both AD and vascular pathology and that have previously proven effective in the treatment of dementia syndromes should be considered as therapeutic options.
|
||||
|
||||
The Ginkgo biloba extract EGb $7 6 1 ^ { \textregistered }$ interferes with various pathomechanisms relevant to dementia, such as $\mathsf { A } \beta$ aggregation and toxicity (Wu et al., 2006), mitochondrial dysfunction (Abdel-Kader et al., 2007) and compromised hippocampal neurogenesis (Tchantchou et al., 2007). EGb $7 6 1 ^ { \textregistered }$ decreases blood viscosity and
|
||||
|
||||
enhances microperfusion (Költringer et al., 1995). In a recent study, EGb $7 6 1 ^ { \textregistered }$ specifically increased dopamine levels in rat pre-frontal cortex, a region involved in working memory and monitoring of actions (Yoshitake et al., 2010). Recent reviews and meta-analyses of randomised controlled trials concluded that EGb $7 6 1 ^ { \textregistered }$ is effective in the treatment of dementia, including Alzheimer’s disease, vascular dementia and mixed forms (Weinmann et al., 2010; Janssen et al., 2010). The drug seems to be particularly useful when dementia is accompanied by neuropsychiatric symptoms (NPS) (Weinmann et al., 2010; Ihl et al., 2010; Schneider et al., 2005). Inconclusive results have been achieved in prevention trials completed so far (DeKosky et al., 2008; Vellas et al., 2010), yet prevention is something clearly distinct from symptomatic treatment.
|
||||
|
||||
The objective of the present clinical trial was to confirm the findings of a preceding study of the same dosage regimen of EGb $7 6 1 ^ { \textregistered }$ in dementia (Ihl et al., 2011) and to further substantiate clinically relevant treatment effects of the once-daily formulation containing $2 4 0 ~ \mathrm { m g }$ of G. biloba extract EGb $7 6 1 ^ { \textregistered }$ on cognition and psychopathology in patients with AD and VaD, both associated with NPS.
|
||||
|
||||
# 2. Patients and methods
|
||||
|
||||
The clinical trial was conducted in accordance with the Declaration of Helsinki, the harmonized tripartite guideline for Good Clinical Practice (GCP) issued by the International Conference on Harmonization (ICH, 1996) and the requirements of the local legislation. The protocol was approved by the National Ethics Committee of the Republic of Moldova, the Ethics Committee of the Republican Clinical Mental Hospital Minsk, Republic of Belarus, and the Ethics Committee under the Federal Service on Surveillance in Healthcare and Social Development of the Russian Federation. Oral and written informed consent was obtained from all patients and caregivers before any trial-related procedures were undertaken. Investigators and clinical staff involved in the clinical trial underwent comprehensive briefing on the provisions of applicable laws and international GCP standards by experienced clinical research professionals.
|
||||
|
||||
# 2.1. Clinical trial population
|
||||
|
||||
Outpatients were recruited at 17 psychiatry or neurology clinics in three countries with Russian-speaking populations (Republic of Belarus, Republic of Moldova, and Russian Federation). These countries were chosen, because cholinesterase inhibitor use was not common and the quick recruitment of untreated patients with dementia speaking the same language to a placebo-controlled study was possible. Patients were eligible for this clinical trial if they were at least 50 years of age (no upper limit) and were suffering from mild to moderate AD or VaD. The diagnosis was based on the following criteria: a) probable AD in accordance with the NINCDS-ADRDA criteria (McKhann et al., 1984), b) possible AD with cerebro-vascular disease (CVD) as defined by the NINDS-AIREN criteria (Román et al., 1993), or c) probable VaD according to NINDS-AIREN. Symptoms of dementia had to have been present for at least 6 months. An MRI scan, consistent with the diagnosis of AD, VaD or AD with CVD and without evidence of other brain lesions, recorded not longer than 1 year prior to the screening visit had to be available.
|
||||
|
||||
The “Test for Early Detection of Dementia with Discrimination from Depression” (TE4D) (Ihl et al., 2000; Mahoney et al., 2005) was used as a screening instrument and to verify the presence of cognitive impairment in at least two domains. This was preferred to the “Mini-Mental State Examination” (MMSE) because of its higher
|
||||
|
||||
sensitivity and specificity to discriminate between demented and non-demented subjects (Mahoney et al., 2005; Ihl et al., 2005). A total score of 35 or below, i.e. in the range indicating dementia, was required for inclusion. Patients were shown to have mild to moderate dementia as demonstrated by a total score from 9 to 23 (both inclusive) on the SKT test battery (Syndrom-Kurztest) (Erzigkeit, 1992; Kim et al., 1993), which roughly corresponds to a range from 14 to 25 on the MMSE or 17 to 35 on the cognitive subscale of the Alzheimer’s Disease Assessment Scale (ADAS-cog) (Ihl et al., 1999). The “Clock-Drawing Test” (CDT) (Sunderland et al., 1989) was used as a second screening instrument, the score of which had to be below 6. Patients were required to have a total score of at least 6 on the 12-item Neuropsychiatric Inventory (NPI) (Cummings, 1997), with at least one of the items “anxiety”, “apathy/ indifference”, “irritability/lability” or “depression/dysphoria” rated 4 or higher. Severe depression was excluded by requiring a score below 20, with item 1 rated no higher than 3 on the 17-item “Hamilton Rating Scale for Depression” (HAMD) (Hamilton, 1960).
|
||||
|
||||
The presence of a caregiver/close relative who was willing to ensure that the patient complied with all aspects of the protocol was required: This included ensuring regular drug intake, reporting adverse events, accompanying the patient to the clinical visits and providing information about the patient. This person had to have regular personal contact with the patient on at least 4 days a week and to participate actively in caring for the patient in order to be able to provide information on the patient’s behaviour and ability to perform activities of daily living.
|
||||
|
||||
Patients were excluded from the clinical trial if they had any other type of dementia or neurological disorder, major short-term fluctuations in symptom severity, current or recent major depression or other psychiatric disorder, severe or insufficiently controlled cardiovascular, renal, or hepatic disorder, diabetes, anaemia, or thyroid dysfunction. Patients suffering from active malignant disease, HIV, syphilis infection or gastrointestinal disease with uncertain absorption were not acceptable. Treatment with other anti-dementia drugs, cognitive enhancers, cholinergic, anticholinergic or haemorheologically active drugs, anti-Parkinson drugs or Ginkgo products was prohibited during the clinical trial and for at least 8 weeks preceding randomisation.
|
||||
|
||||
# 2.2. Clinical trial design and intervention
|
||||
|
||||
This was a double-blind, parallel-group, multi-centre clinical trial. A screening period (up to 4 weeks) was required for examinations and washout of discontinued medications, followed by a 24-week treatment period. Randomisation (1:1, stratified by centre and in blocks of four), was performed with the aid of a validated computer program that linked random numbers to drug or placebo treatment, respectively. The sealed randomisation code was stored safely at the biometrics unit, and the length of randomisation blocks within which numbers of drug and placebo were balanced was not disclosed to investigators. Upon successful screening, each patient was assigned the treatment package with the lowest drug number still available at a site. Active drug and placebo tablets were indistinguishable in appearance, packaging and labelling. Patients were requested to take one tablet in the morning. Drug dispensation and return was handled by persons not involved otherwise in the conduct of the clinical trial, usually the hospital pharmacists. The investigational product, EGb 761,2 is a dry extract from G. biloba leaves (35e67:1), extraction solvent: acetone $6 0 \%$ (w/w). The extract is adjusted to $2 2 . 0 { - } 2 7 . 0 \%$ ginkgo
|
||||
|
||||
flavonoids calculated as ginkgo flavone glycosides and $5 . 0 { - } 7 . 0 \%$ terpene lactones consisting of $2 . 8 { - } 3 . 4 \%$ ginkgolides A, B, C and $2 . 6 { - } 3 . 2 \%$ bilobalide, and contains less than 5 ppm ginkgolic acids. In this trial, a once-daily formulation containing $2 4 0 ~ \mathrm { m g }$ of EGb $7 6 1 ^ { \textregistered }$ per tablet was used.
|
||||
|
||||
# 2.3. Outcome measures
|
||||
|
||||
Primary efficacy measures were the SKT, a 9-item cognitive test battery, the score of which ranges from 0 to 27, and the 12-item NPI, which assesses frequency and severity of neuropsychiatric symptoms (composite score) and ranges from 0 to 144. On both scales, higher scores indicate more severe impairment. The SKT was used for cognitive testing because it is well validated across many cultures and languages, including Russian (Lehfeld et al., 1997a,b). For memory testing it uses images instead of word lists to circumvent the problem of creating equivalent word lists in different languages, and in the stage of dementia scores are equivalent across countries (Lehfeld et al., 1997a,b), which is not generally the case for the cognitive subscale of the Alzheimer’s Disease Assessment Scale (Verhey et al., 2004). An NPI version in Russian language was used that had been culturally and linguistically validated following standard procedures.
|
||||
|
||||
The NPI caregiver distress score (range 0e60); the Clinical Global Impression of Change (CGIC) as adapted by the Alzheimer’s Disease Cooperative Study (ADCS) (Schneider et al., 1997); the Alzheimer’s Disease Activities of Daily Living International Scale (ADL-IS) (Reisberg et al., 2001); rates of clinically meaningful response in primary outcomes; the DEMQOL-Proxy, which is a health-related quality-of-life scale for people with dementia (Smith et al., 2005); and the Verbal Fluency Test (animal fluency, as adapted by Mahoney et al. (2005)) were chosen as secondary efficacy variables. Patient self-ratings of presence and severity of dizziness and tinnitus were documented using 11-point box scales, 0 representing absence and 10 indicating extreme severity of a symptom. All cognitive tests and all interviews, except the ADCS-CGIC, were administered by the investigators and sub-investigators all of whom were psychiatrists or neurologists. The ADCS-CGIC ratings were done by independent physicians or psychologists who were not otherwise involved in the trial or the treatment of the patients and who had no knowledge of the results of other ratings and cognitive tests. Investigators and investigational staff were trained in the administration of tests and scales by an experienced geriatric psychiatrist and neuropsychologist. This involved demonstration and exercises using original scales and test kits. All efficacy assessments were performed at baseline, at week 12 and 24.
|
||||
|
||||
Safety was evaluated by physical examination, electrocardiography and laboratory tests at screening and week 24. Adverse events were recorded at all visits and during phone calls at weeks 6 and 18.
|
||||
|
||||
# 2.4. Sample size, data sets and statistical analysis
|
||||
|
||||
The trial was conducted to confirm the clinical efficacy of a oncedaily formulation of 240 mg EGb $7 6 1 ^ { \textregistered }$ in patients suffering from AD, VaD or AD with vascular components, all with neuropsychiatric symptoms. In the confirmatory analysis, this was done for both the primary cognitive efficacy variable “change of the SKT total score” and the primary variable for the assessment of the effects on neuropsychiatric symptoms “change of the NPI total composite score (items 1e12)” between baseline and week 24. The intersection-union principle (Berger, 1982) was used to test the hypothesis concerning the difference between $\mathsf { E G b } ^ { \mathbb { B } }$ 761 and placebo with respect to both endpoints. Thus, a difference between
|
||||
|
||||
EGb $7 6 1 ^ { \textregistered }$ and placebo could be established at a two-sided type I error rate of $\alpha = 0 . 0 5$ if both single null-hypotheses (no difference in the change of the SKT total score, no difference in the NPI composite score) were rejected at level $\alpha = 0 . 0 5$ (two-sided). The two single null-hypotheses were tested applying an analysis of covariance model with the factors treatment and centre and the baseline value of the respective variable as a covariate.
|
||||
|
||||
The confirmatory analysis was primarily based on the full analysis set (FAS) including all patients who received randomised clinical trial medication at least once and having at least one measurement of the primary efficacy parameters during the randomised treatment period. Missing Data were handled by the last observation carried forward method (LOCF-method).
|
||||
|
||||
A sensitivity analysis (per protocol analysis) was performed including all patients of the full analysis set without major protocol violations (per protocol set, PPS). All patients who received randomised clinical trial medication at least once were analysed with regard to safety measures (safety analysis set, SAF).
|
||||
|
||||
The sample size was determined to provide a power of at least $9 5 \%$ for the rejection of the two null-hypotheses for each of the primary variables SKT and NPI at a type I error rate of $\alpha = 0 . 0 5$ . This assures a power of at least $9 0 \%$ for the simultaneous rejection of both null-hypotheses. The sample size calculation was based on a common standard deviation of 4 points for the change of the SKT total score and 7 points for the change of the NPI total score, each between baseline and week 24. The sample size was assumed to be high enough to detect a clinically relevant difference between the treatment groups of at least 2 points with respect to change of the SKT total score and a difference of at least 2.5 points with respect to the change of the NPI total score. Under these assumptions, $2 \times 2 0 5 = 4 1 0$ patients were needed for the full analysis set in order to fulfil the power requirements given above if the two sample ttest were to be used for the analysis.
|
||||
|
||||
# 3. Results
|
||||
|
||||
# 3.1. Patient sample
|
||||
|
||||
Of 472 patients who were screened for eligibility, 410 were randomised and 62 failed screening and did not take any trial medication. All randomised patients received the allocated treatment at least once and were included in the SAF. The first patient entered the clinical trial in March 2008, and the last patient was completed in October 2009.
|
||||
|
||||
Eight patients of the SAF could not be included in the FAS due to missing efficacy assessments after baseline evaluation. Thus the FAS, on which the primary statistical analysis was based, consisted of 402 patients. The PPS comprised 371 patients without relevant protocol deviations. Since both FAS and PP analyses yielded nearly identical results, only those for the FAS analysis are provided. Patient disposition is depicted in Fig. 1, demographic characteristics and baseline data are summarised in Table 1. No clinically relevant differences were detected between the treatment groups.
|
||||
|
||||
Most patients had concomitant diseases at baseline, the most common of which were vascular disorders (EGb $7 6 1 ^ { \textregistered }$ : $8 6 \%$ placebo: $8 5 \%$ ; mainly hypertension and atherosclerosis), nervous system disorders (EGb $7 6 1 ^ { \textregistered }$ : $8 5 \%$ , placebo: $8 0 \%$ ; mainly dizziness and CVD), cardiac disorders (EGb $7 6 1 ^ { \textregistered }$ : $5 5 \%$ , placebo: $45 \%$ ; mainly coronary artery disease), and ear and labyrinth disorders (EGb $7 6 1 ^ { \textcircled { \mathrm {8 } } }$ : $5 2 \%$ , placebo: $4 9 \%$ ; mainly tinnitus). More than $7 5 \%$ of the patients were taking concomitant medications when starting trial medication, with agents acting on the renineangiotensin system (EGb $7 6 1 ^ { \textregistered }$ : $5 4 \%$ , placebo: $4 8 \%$ ), antithrombotic agents (EGb $7 6 1 ^ { \textregistered }$ : $2 9 \%$ , placebo: $23 \%$ ) and beta blocking agents (EGb $7 6 1 ^ { \textregistered }$ : $1 8 \%$ placebo: $1 8 \%$ ) being the most frequently taken types of drugs.
|
||||
|
||||

|
||||
Fig. 1. Patient disposition and flow.
|
||||
|
||||
Table 1 Baseline characteristics of the full analysis set; means $\pm$ standard deviations and $p$ - values of the two-sided t-test or numbers (percent) and $p$ -values of the two-sided Chi-square test.
|
||||
|
||||
<table><tr><td></td><td></td><td>EGb 761®(N = 200)</td><td>Placebo(N = 202)</td><td>p-value</td></tr><tr><td>Sex female</td><td></td><td>139 (69.5)</td><td>140 (69.3)</td><td>0.966</td></tr><tr><td>Age [y]</td><td></td><td>65.1 ± 8.8</td><td>64.9 ± 9.4</td><td>0.870</td></tr><tr><td>Weight [kg]</td><td></td><td>75.7 ± 13.3</td><td>72.5 ± 12.8</td><td>0.017</td></tr><tr><td>Height [cm]</td><td></td><td>166.9 ± 7.8</td><td>166.1 ± 7.4</td><td>0.282</td></tr><tr><td>Duration of memory problems [y]</td><td></td><td>3.2 ± 2.2</td><td>3.4 ± 2.8</td><td>0.815</td></tr><tr><td rowspan="3">Type of dementia</td><td>Probable AD</td><td>107 (54)</td><td>101 (50)</td><td rowspan="3">0.781</td></tr><tr><td>Possible AD with CVD</td><td>73 (36)</td><td>79 (39)</td></tr><tr><td>Probable VaD</td><td>20 (10)</td><td>22 (11)</td></tr><tr><td>SKT total score</td><td></td><td>15.1 ± 4.1</td><td>15.3 ± 4.2</td><td>0.593</td></tr><tr><td>NPI total score</td><td></td><td>16.8 ± 6.9</td><td>16.7 ± 6.4</td><td>0.961</td></tr><tr><td>NPI caregiver distress score</td><td></td><td>10.2 ± 5.3</td><td>10.1 ± 5.1</td><td>0.883</td></tr><tr><td>Verbal Fluency Test</td><td></td><td>7.6 ± 1.9</td><td>7.6 ± 1.9</td><td>0.769</td></tr><tr><td>TE4D cognitive scorea</td><td></td><td>29.4 ± 4.0</td><td>28.9 ± 4.8</td><td>0.234</td></tr><tr><td>TE4D depression scorea</td><td></td><td>3.9 ± 3.3</td><td>4.0 ± 3.6</td><td>0.814</td></tr><tr><td>ADL-IS mean score</td><td></td><td>1.7 ± 0.6</td><td>1.8 ± 0.6</td><td>0.573</td></tr><tr><td>DEMQOL-Proxy total score</td><td></td><td>85.7 ± 10.6</td><td>86.0 ± 10.3</td><td>0.810</td></tr><tr><td>11-point box scale tinnitus</td><td></td><td>1.4 ± 1.8</td><td>1.4 ± 1.8</td><td>0.996</td></tr><tr><td>11-point box scale dizziness</td><td></td><td>2.0 ± 1.8</td><td>1.8 ± 1.8</td><td>0.280</td></tr></table>
|
||||
|
||||
a TE4D scores at screening, the test was not repeated at baseline.
|
||||
|
||||
Overall, the treatment groups were well balanced with regard to medical history and concomitant medication.
|
||||
|
||||
Use of psychoactive drugs was rare: Three patients took antipsychotics (EGb $7 6 1 ^ { \textregistered }$ : 2, placebo 1) for short periods (up to 14 days), two patients (both on EGb $7 6 1 ^ { \textregistered }$ ) took anxiolytics for two to four days, and six patients (EGb $7 6 1 ^ { \textregistered }$ : 5, placebo: 1) occasionally took sedatives (only valerian/valerate products). Prior short-term treatment with nootropics or cholinesterase inhibitors (ChEI) was reported for 13 patients (ChEI: 1) randomised to EGb $7 6 1 ^ { \textregistered }$ and for 24 patients (ChEI: 3) of the placebo group. Caregivers were immediate family (adult child, $4 4 \%$ ), spouses $( 3 1 \% )$ or other relatives or friends $( 2 5 \% )$ .
|
||||
|
||||
Compliance with the investigational treatment regimen, estimated from the number of unused tablets returned at week 12 and week 24, turned out to be very good during the whole double-blind treatment period (EGb $7 6 1 ^ { \textregistered }$ : $9 9 . 6 \pm 7 . 3 \%$ ; placebo: $9 9 . 8 \pm 1 . 8 \%$ ).
|
||||
|
||||
# 3.2. Primary outcome measures
|
||||
|
||||
Patients treated with EGb $7 6 1 ^ { \textregistered }$ improved in both cognitive test performance and neuropsychiatric symptoms, whereas there was little change in the placebo group. This resulted in statistically significant superiority of EGb $7 6 1 ^ { \textregistered }$ over placebo in both primary outcome measures $p < 0 . 0 0 1$ , Table 2, Figs. 2 and 3).
|
||||
|
||||
In individual patients, a decrease in the SKT total score by at least 3 points, which is fairly equivalent to a 4-point decrease in the ADAS-cog score, is considered as clinically meaningful (Ihl et al., 1999; Rogers et al., 1998), and so is a decrease by 4 points or more in the NPI total score (Mega et al., 1999). So defined clinically significant responses were observed more frequently among patients treated with EGb $7 6 1 ^ { \textregistered }$ (SKT: $4 3 \%$ , NPI $5 7 \%$ ) than in those taking placebo (SKT: $23 \%$ , NPI: $3 9 \%$ ). The differences between treatment groups were statistically significant for both comparisons $( p < 0 . 0 0 1 )$ ).
|
||||
|
||||
Improvements and response rates under EGb $7 6 1 ^ { \textregistered }$ treatment were similar for patients with probable AD and those with CVD (i.e. possible AD with CVD or probable VaD), whereas placebo response was slightly higher in patients with CVD. Due to the well-known low sensitivity of the NINDS/AIREN criteria for probable vascular dementia this subgroup was too small to perform meaningful analyses.
|
||||
|
||||
A quantitative interaction of treatment by centre was observed (Källén, 1997). In 14 of 15 centres analysed (at least 16 patients each; 3 smaller centres were pooled) the effects of EGb $7 6 1 ^ { \textregistered }$ were more pronounced than in the placebo group for at least one of the primary efficacy parameters.
|
||||
|
||||
Table 2
|
||||
Changes from baseline in primary and secondary outcome measures, score at week 24 for ADCS-CGIC; full analysis set; means $9 5 \%$ confidence intervals); $p$ -values of the ANCOVA for the primary efficacy variables and of two-sided t-test for secondary efficacy variables.
|
||||
|
||||
<table><tr><td></td><td>EGb 761® (N = 200)</td><td>Placebo (N = 202)</td><td>p-value</td></tr><tr><td>SKT total score</td><td>-2.2 (-2.7; -1.8)</td><td>-0.3 (-0.9; 0.2)</td><td><0.001a</td></tr><tr><td>NPI total score</td><td>-4.6 (-5.6; -3.6)</td><td>-2.1 (-3.0; -1.2)</td><td><0.001a</td></tr><tr><td>NPI caregiver distress score</td><td>-2.4 (-3.1; -1.8)</td><td>-0.5 (-1.1; 0.0)</td><td><0.001</td></tr><tr><td>ADCS-CGIC</td><td>3.1 (3.0; 3.3)</td><td>3.8 (3.6; 4.0)</td><td><0.001</td></tr><tr><td>ADL-IS overall mean score</td><td>-0.11 (-0.16; -0.06)</td><td>0.04 (0.0; 0.08)</td><td><0.001</td></tr><tr><td>DEMQOL-Proxy total score</td><td>3.5 (2.2; 4.7)</td><td>1.5 (0.3; 2.7)</td><td>0.027</td></tr><tr><td>Verbal Fluency Test</td><td>0.7 (0.3; 1.0)</td><td>0.0 (-0.3; 0.3)</td><td><0.01</td></tr><tr><td>TE4D cognitive scoreb</td><td>2.9 (2.2; 3.6)</td><td>0.7 (0.2; 1.3)</td><td><0.001</td></tr><tr><td>11-point box scale dizziness</td><td>-0.6 (-0.8; -0.5)</td><td>-0.2 (-0.4; -0.1)</td><td><0.001</td></tr><tr><td>11-point box scale tinnitus</td><td>-0.4 (-0.5; -0.2)</td><td>-0.3 (-0.4; -0.1)</td><td>0.31</td></tr></table>
|
||||
|
||||
a $p$ -value of the analysis of covariance with treatment and centre as factors and the baseline value as covariate.
|
||||
b TE4D difference to screening, the test was not done at baseline.
|
||||
|
||||

|
||||
Fig. 2. Change in SKT total score from baseline to week 24; full analysis set $n = 4 1 0 ^ { \cdot }$ ); means and $9 5 \%$ confidence intervals; $^ { * * } p < 0 . 0 0 1$ , ANCOVA with treatment and centre as factors and the baseline value as covariate.
|
||||
|
||||
# 3.3. Secondary outcome measures
|
||||
|
||||
There was a consistent statistically and clinically relevant superiority of EGb $7 6 1 ^ { \textcircled { \mathrm {8 } } }$ over placebo across most of the secondary outcome measures (Table 2), including improved ability to cope with the demands of everyday living, improved quality of life and clinicians’ global judgement (Table 2, Fig. 4).
|
||||
|
||||
# 3.4. Safety and tolerability
|
||||
|
||||
Between baseline and two days after the end of the treatment period, 119 adverse events (AEs) were reported by 91 patients $( 4 4 . 4 \% )$ treated with EGb $7 6 1 ^ { \textregistered }$ and 126 AEs were documented for 82 patients $( 4 0 . 0 \% )$ of the placebo group. AEs observed for at least three per cent of patients in either treatment group are summarized in Table 3. No major differences were discernible, except for dizziness, which was more than three times more frequent in the placebo group $( 7 . 3 \% )$ than in the active treatment group $( 2 . 0 \% )$ . For 59 AEs in 50 patients of the EGb $7 6 1 ^ { \textregistered }$ group and for 61 AEs in 46 patients of the placebo group a causal relationship could not be ruled out in double-blind assessment. There was no event of bleeding or impaired blood clotting in the group treated with EGb $7 6 1 ^ { \circledast }$ .
|
||||
|
||||
Three serious adverse events (SAEs) were observed during treatment with active drug: a lethal cardiac arrest due to chronic heart failure in a patient suffering from multiple illnesses; a lethal
|
||||
|
||||

|
||||
Fig. 3. Change in NPI composite score from baseline to week 24; full analysis set $n = 4 1 0 ^ { \cdot }$ ); means and $9 5 \%$ confidence intervals; $\stackrel { * } { p } < 0 . 0 1$ , $^ { * * } p < 0 . 0 0 1$ , ANCOVA with treatment and centre as factors and the baseline value as covariate.
|
||||
|
||||

|
||||
Fig. 4. Clinical global impression of change (ADCS-CGIC) at week 24; full analysis set $n = 4 1 0 ^ { \cdot }$ ); $p < 0 . 0 0 1$ , Chi-square test.
|
||||
|
||||
ischaemic infarction in the region of the terminal branches of the middle and posterior cerebral arteries in a patient with a history of diabetes mellitus, hypertension, atherosclerosis, myocardial infarction and a previous stroke; and a transitory ischaemic attack in the region of the left medial cerebral artery in a patient with insufficiently controlled arterial hypertension. One SAE, a death due to pneumonia, probably caused by aspiration due to a bulbar syndrome, was reported in the placebo group. All SAEs were judged by the investigators to be related to concomitant conditions and unrelated to the trial medication.
|
||||
|
||||
The incidence and the profile of AEs reflect the typical pattern of events expected in elderly patients with dementia and cerebrovascular disease. The most common adverse event potentially related to treatment was headache, which is an unspecific symptom that is often reported by patients in any study, yet the frequency of headaches was slightly higher in the placebo group (EGb $7 6 1 ^ { \textregistered }$ : $5 . 9 \%$ , placebo: $6 . 3 \%$ ).
|
||||
|
||||
Physical and neurological examination, 12-lead ECG, blood pressure, heart rate, and laboratory tests did not reveal any conspicuous or systematic changes under EGb $7 6 1 ^ { \textregistered }$ treatment.
|
||||
|
||||
# 4. Discussion
|
||||
|
||||
The present clinical trial demonstrates the efficacy of the oncedaily administration of EGb $7 6 1 ^ { \textregistered }$ at a dose of $2 4 0 ~ \mathrm { m g }$ in the treatment of dementia. Active treatment was significantly superior to placebo in improving patients’ cognitive performance and neuropsychiatric symptoms which were the primary outcomes of the study. Using the NPI as co-primary outcome measure, rather than a functional or global assessment was a logical consequence of the
|
||||
|
||||
Table 3 Adverse events reported by at least $3 \%$ of patients of either treatment group with onset during and within two days after randomised treatment (safety analysis set); number and per cent of patients with adverse events.
|
||||
|
||||
<table><tr><td rowspan="2"></td><td colspan="2">EGb 761® (N = 205)</td><td colspan="2">Placebo (N = 205)</td><td rowspan="2">EGb 761® – placebo Diff. of rates with 95% CIa</td></tr><tr><td>N</td><td>%</td><td>N</td><td>%</td></tr><tr><td>Headache</td><td>15</td><td>7.3</td><td>17</td><td>8.3</td><td>-1.0 [-6.4; 4.4]</td></tr><tr><td>Dizziness</td><td>4</td><td>2.0</td><td>15</td><td>7.3</td><td>-5.4 [-9.9; -1.3]</td></tr><tr><td>Viral respiratory tract infection</td><td>8</td><td>3.9</td><td>6</td><td>2.9</td><td>1.0 [-2.9; 4.9]</td></tr><tr><td>Hypertension</td><td>5</td><td>2.4</td><td>8</td><td>3.9</td><td>-1.5 [-5.3; 2.2]</td></tr><tr><td>Somnolence</td><td>8</td><td>3.9</td><td>4</td><td>2.0</td><td>2.0 [-1.6; 5.8]</td></tr><tr><td>Upper abdominal pain</td><td>5</td><td>2.4</td><td>7</td><td>3.4</td><td>-1.0 [-4.7; 2.6]</td></tr></table>
|
||||
|
||||
a $9 5 \%$ confidence intervals calculated according to Newcombe (1998), Method 10.
|
||||
|
||||
choice of the target population, i.e. patients with clinically significant NPS. The clinical significance of the drug effects was demonstrated by the consistency of both primary outcomes and corroborated by the secondary outcomes, including functional abilities, global assessment, quality of life and response rates. As a consequence, the distress experienced by caregivers due to the patients’ aberrant behaviours was alleviated.
|
||||
|
||||
The results are in line with those from former well-controlled clinical trials of EGb $7 6 1 ^ { \textregistered }$ which, according to the most recent reviews and meta-analyses, demonstrate efficacy and clinical benefits of EGb $7 6 1 ^ { \textregistered }$ in the treatment of dementia (Weinmann et al., 2010; Janssen et al., 2010). In particular, they are consistent with the findings from a recently published, independent trial using the same once-daily formulation of EGb $7 6 1 ^ { \textregistered }$ and an identical design (Ihl et al., 2011), thus confirming efficacy and safety of the 240 mg once-daily regimen.
|
||||
|
||||
In contrast to many anti-dementia drug trials performed during the 1990s, we did not exclude patients with clinically significant NPS, but decided to specifically select such patients. Taking into account that up to $8 0 \%$ of patients with dementia have NPS, with depression, apathy and anxiety being the most prevalent symptoms (Di Iulio et al., 2010; Steinberg et al., 2008), our patient sample was probably more representative of dementia patients seen in everyday practice. There have been two concerns about patients with NPS in anti-dementia drug trials: improving cognitive performance with drugs that have anti-depressant rather than cognition-enhancing effects and interference of the effects of psychoactive drugs with efficacy assessments. The latter is negligible, since only a low rate of participants received an additional psychopharmacological medication. The former is unjustified, because Powlishta et al. (2004) demonstrated that in patients with dementia, cognitive performance is determined by the severity of dementia and is not further aggravated by depression. Hence, improvement in cognitive function is not to be expected by antidepressant treatment alone.
|
||||
|
||||
The low average age of the patients and the enrolment of outpatients only might limit the generalizability of our findings. The average age reflects the life expectancy in Eastern European countries, which is clearly lower than in the Western world. However, the considerable burden of co-morbidity found in our patient sample seems to compare fairly well with that of older patients with dementia seen in Western countries. Moreover, the age range extends to 87 years and there was no relationship between age and treatment outcomes in this trial, suggesting that the study results were not biased by the patients’ age. A considerable proportion of patients with dementia live in nursing homes, but due to the limited requirements and possibilities to perform instrumental activities of daily living in a nursing home, ADL scales used in studies of outpatients would not be feasible for inpatients. Taking into account, however, that caregiver distress related to NPS is a significant predictor of nursing home placement (de Vugt et al., 2005), our outpatient sample was probably not fundamentally different from nursing home patients.
|
||||
|
||||
According to Bornhöft et al. (2006), one of the fundamental questions regarding the external validity of a clinical trial is to what extent the inclusion and exclusion criteria define the “everyday or target” population. The patients included in the present study were selected by the widely used diagnostic criteria proposed by the NINCDS/ADRDA (McKhann et al., 1984) and NINDS/AIREN (Román et al., 1993) working groups. Due to the rare use of cholinesterase inhibitors and memantine in Eastern European countries, the study sample could be drawn from the broad spectrum of dementia patients normally referred to investigators’ clinics rather than from minority groups of patients who either did not respond to or did not tolerate other treatments. Since the pharmacodynamics of EGb
|
||||
|
||||
$7 6 1 ^ { \textregistered }$ interfere with both Alzheimer’s and cerebro-vascular pathology, there was no need to exclude patients with VaD or those with AD and CVD, who represent the largest proportion of patients with dementia (Schneider et al., 2007; Korczyn, 2002). For methodological reasons, it is not possible to admit all patients seeking treatment for dementia to a phase-III study. However, by recruiting our study sample from the vast majority of dementia patients who have NPS (Di Iulio et al., 2010; Steinberg et al., 2008), by admitting the large proportion of patients with cerebro-vascular pathology (Schneider et al., 2007; Korczyn, 2002), and by running the study in a region where the recruitment of unmedicated dementia patients was possible, we achieved a sample that can be considered to be representative of the “everyday or target” population. Hence, there is reason to assume that our findings are relevant to the patients with mild to moderate dementia encountered in daily practice.
|
||||
|
||||
The validity of outcome measures most of which were developed in Western countries needs consideration. As mentioned above, the SKT battery, which may not be used as commonly as the ADAS-cog, is particularly feasible for multi-national research programs, because it uses images instead of word lists to test memory functions. The test has been validated in cross-cultural studies including Russian-speaking populations (Lehfeld et al., 1997a,b), and SKT total scores are highly correlated with ADAScog scores, suggesting that to a large extent both tests measure the same abilities (Ihl et al., 1999). The NPI is a widely used rating scale for the evaluation of severity and treatment-related changes of NPS that has been adapted linguistically and culturally in accordance with standard procedures for administration in Russian-speaking countries. To further demonstrate the clinical relevance of cognitive improvement in terms of activities of daily living and a global rating, as required by current guidelines (EMEA, 2008), we also administered the ADL-IS and the ADCS-CGIC, which consistently indicated superiority of EGb $7 6 1 ^ { \textregistered }$ over placebo. The ADL-IS was developed and validated in an international research project in which, among Japan, Western European and North American countries, Russia was involved (Lehfeld et al., 1997b). The 7-point scale of the ADCS-CGIC as well as the instructions and key words of the working sheets, although easy to understand, were adapted by forward and backward translation and reconciliation by a bilingual psychiatrist. Altogether, the assessment instruments used in the present study can be regarded as feasible and valid for use in Russian-speaking countries.
|
||||
|
||||
Overall, the present clinical trial corroborates findings from previous studies that showed G. biloba extract EGb $7 6 1 ^ { \textcircled { \mathbb { \mathbb { B } } } } ,$ , and specifically at a 240 mg daily dose, to be both safe and effective in the treatment of patients with dementia associated with neuropsychiatric features.
|
||||
|
||||
# Role of funding source
|
||||
|
||||
Employees of the sponsor (RH, SS) were involved in the planning and conduct of the trial, in the data analysis and interpretation as well as in the preparation of the manuscript.
|
||||
|
||||
# Contributors
|
||||
|
||||
H. Herrschaft was involved in the development of the trial protocol and in quality assurance measures. A. Nacu was the Coordinating Investigator, S. Likhachev and I. Sholomov were investigators in the trial and were involved in data collection. R. Hoerr designed the study and wrote the trial protocol, S. Schlaefke was in charge of data management and analysis. All authors contributed to and have approved the final manuscript.
|
||||
|
||||
# Author disclosure
|
||||
|
||||
H. Herrschaft has consulted for Dr. Willmar Schwabe GmbH & Co. KG. A. Nacu, S. Likhachev and I. Sholomov participated in the study as investigators and received investigator fees. R. Hoerr and S. Schlaefke are employees of Schwabe receiving fixed salaries.
|
||||
|
||||
# Acknowledgements
|
||||
|
||||
Dr. Willmar Schwabe GmbH & Co. KG Pharmaceuticals was the sponsor of the clinical trial.
|
||||
|
||||
# References
|
||||
|
||||
Abdel-Kader R, Hauptmann S, Keil U, Scherping I, Leuner K, Eckert A, et al. Stabilization of mitochondrial function by Ginkgo biloba extract (EGb $7 6 1 ^ { \textcircled { \mathbb { B } } }$ ). Pharmacological Research 2007;56:493e502.
|
||||
Berger RL. Multiparameter hypothesis testing and acceptance sampling. Technometrics 1982;24:295e300.
|
||||
Bornhöft G, Maxion-Bergemann S, Wolf U, Kienle GS, Michalsen A, Vollmar HC, et al. Checklist for the qualitative evaluation of clinical studies with particular focus on external validity and model validity. BMC Medical Research Methodology 2006;6:56.
|
||||
Cummings JL. The Neuropsychiatric Inventory: assessing psychopathology in dementia patients. Neurology 1997;48(Suppl. 6):S10e6.
|
||||
Daviglus ML, Bell CC, Berrettini W, Bowen PE, Connolly ES, Cox NJ, et al. National institutes of health state-of-the-science conference statement: preventing Alzheimer’s disease and cognitive decline. NIH Consensus and State-of-the-Science Statements 2010;27:1e30, http://consensus.nih.gov/2010/docs/alz/ ALZ_Final_Statement.pdf [accessed 28.10.11].
|
||||
DeKosky ST, Williamson JD, Fitzpatrick AL, Kornmal RA, Ives DG, Saxton JA, et al. Ginkgo biloba for prevention of dementia. A randomized controlled trial. Journal of the American Medical Association 2008;300:2253e62.
|
||||
de Vugt ME, Stevens F, Aalten P, Lousberg R, Jaspers N, Verhey FRJ. A prospective study of the effects of behavioral symptoms on the institutionalization of patients with dementia. International Psychogeriatrics 2005;17:1e13.
|
||||
Di Iulio F, Palmer K, Blundo C, Casini AR, Gianni W, Caltagirone C, et al. Occurrence of neuropsychiatric symptoms and psychiatric disorders in mild Alzheimer’s disease and mild cognitive impairment subtypes. International Psychogeriatrics 2010;22:629e40.
|
||||
EMEA European Medicines Agency. Guideline on medicinal products for the treatment of Alzheimer’s disease and other dementias [CPMP/EWP/553/95 Rev 1]. London: EMEA; 2008.
|
||||
Erzigkeit H. SKT manual. A short cognitive performance test for assessing memory and attention. Concise version. Geromed: Castrop-Rauxel; 1992.
|
||||
Förstl H. Alzheimer-plus. International Psychogeriatrics 2003;15:7e8.
|
||||
Green RC, Schneider LS, Amato DA, Beelen AP, Wilcock G, Swabb EA, et al. Effect of tarenflurbil on cognitive decline and activities of daily living in patients with mild Alzheimer disease. Journal of the American Medical Association 2009;302: 2557e64.
|
||||
Hamilton M. A rating scale for depression. Journal of Neurology, Neurosurgery and Psychiatry 1960;23:56e62.
|
||||
Holmes C, Boche D, Wilkinson D, Yadegarfar G, Hopkins V, Bayer A, et al. Long-term effects of $\mathsf { A } \beta _ { 4 2 }$ immunisation in Alzheimer’s disease: follow-up of a randomised, placebo-controlled phase I trial. The Lancet 2008;372:216e23.
|
||||
ICH International Conference on Harmonisation of Technical Requirements for Registration of Pharmaceuticals for Human Use. Good clinical practice: consolidated guideline. Geneva: ICH; 1996.
|
||||
Ihl R, Grass-Kapanke B, Jänner M, Weyer G. Neuropsychometric tests in cross sectional and longitudinal studies e a regression analysis of ADAS-Cog, SKT and MMSE. Pharmacopsychiatry 1999;32:248e54.
|
||||
Ihl R, Grass-Kapanke B, Lahrem P, Brinkmeyer J, Fischer S, Gaab N, et al. Entwicklung und Validierung eines Tests zur Früherkennung der Demenz mit Depressionsabgrenzung (TFDD). Fortschritte der Neurologie e Psychiatrie 2000;68: 413e22.
|
||||
Ihl R, Biesenbach A, Brieber S, Grass-Kapanke B, Salomon T. A head-to-head comparison of the sensitivity of two screening tests for dementia. Mini-Mental-State-Examination (MMSE) and the test for Early Detection of Dementia with Discrimination from Depression (TE4D). Pychogeriatria Polska 2005;2:263e71.
|
||||
Ihl R, Tribanek M, Bachinskaya N. Baseline neuropsychiatric symptoms are effect modifiers in Ginkgo biloba extract (EGb $7 6 1 ^ { \circ }$ ) treatment of dementia with neuropsychiatric features. Retrospective data analyses of a randomized controlled trial. Journal of the Neurological Sciences 2010;299:184e7.
|
||||
Ihl R, Bachinskaya N, Korczyn AD, Vakhapova V, Tribanek M, Hoerr R, et al. Efficacy and safety of a once-daily formulation of Ginkgo biloba extract EGb $7 6 1 ^ { \textregistered }$ in dementia with neuropsychiatric features: a randomized controlled trial. International Journal of Geriatric Psychiatry 2011;26:1186e94. doi:10.1002/gps.2662.
|
||||
Janssen IM, Sturtz S, Skipka G, Zentner A, Garrido MV, Busse R. Ginkgo biloba in Alzheimer’s disease: a systematic review. Wiener Medizinische Wochenschrift 2010;160:539e46.
|
||||
|
||||
Källén A. Treatment-by-center interaction: what is the issue? Drug Information Journal 1997;31:927e36.
|
||||
Kim YS, Nibbelink DW, Overall JE. Factor structure and scoring of the SKT test battery. Journal of Clinical Psychology 1993;49:61e71.
|
||||
Költringer P, Langsteger W, Ober O. Dose-dependent hemorheological effects and microcirculatory modifications following intravenous administration of Ginkgo biloba special extract EGb 761. Clinical Hemorheology 1995;15:649e56.
|
||||
Korczyn AD. Mixed dementia e the most common cause of dementia. Annals of the New York Academy of Sciences 2002;977:129e34.
|
||||
Lehfeld H, Rudinger G, Rietz C, Heinrich C, Wied V, Fornazzari L, et al. Evidence of the cross-cultural stability of the factor structure of the SKT short test for assessing deficits of memory and attention. International Psychogeriatrics 1997a;9:139e53.
|
||||
Lehfeld H, Reisberg B, Finkel S, Kanowski S, Wied V, Pittas J, et al. Informant-rated activities-of-daily-lilving (ADL) assessments: results of a study of 141 items in the USA, Germany, Russia, and Greece from the international ADL scale development project. Alzheimer Disease and Associated Disorders 1997b;11 (Suppl. 4):S39e44.
|
||||
Lindesay J, Bullock R, Daniels H, Emre M, Förstl H, Frölich L, et al. Turning principles into practice in Alzheimer’s disease. International Journal of Clinical Practice 2010;64:1198e209.
|
||||
Mahoney R, Johnston K, Katona C, Maxmin K, Livingston G. The TE4D-Cog: a new test for detecting early dementia in English-speaking populations. International Journal of Geriatric Psychiatry 2005;20:1172e9.
|
||||
McKhann G, Drachman D, Folstein M, Katzman R, Price D, Stadlan EM. Clinical diagnosis of Alzheimer’s disease: report of the NINCDS-ADRDA work group under the auspices of department of health and human services task force on Alzheimer’s disease. Neurology 1984;34:939e44.
|
||||
Mega MS, Masterman DM, O’Connor SM, Barclay TR, Cummings JL. The spectrum of behavioral responses to cholinesterase inhibitor therapy in Alzheimer disease. Archives of Neurology 1999;56:1388e93.
|
||||
Newcombe RG. Interval estimation for the difference between independent proportions: comparison of eleven methods. Statistics in Medicine 1998;17: 873e90.
|
||||
Newman AB, Fitzpatrick AL, Lopez O, Jackson S, Lyketsos C, Jagust W, et al. Dementia and Alzheimer’s disease incidence in relationship to cardiovascular disease in the Cardiovascular Health Study Cohort. Journal of the American Geriatrics Society 2005;53:1101e7.
|
||||
Powlishta KK, Storandt M, Mandernach TA, Hogan E, Grant EA, Morris JC. Absence of effect of depression on cognitive performance in early-stage Alzheimer disease. Archives of Neurology 2004;61:1265e8.
|
||||
Reisberg B, Finkel S, Overall J, Schmidt-Gollas N, Kanowski S, Lehfeld H, et al. The Alzheimer’s disease activities of daily living international scale (ADL-IS). International Psychogeriatrics 2001;13:163e81.
|
||||
Rogers Sl, Farlow MR, Doody RS, Mohs R, Friedhoff LT, the Donepezil Study Group. A 24 week, double-blind, placebo-controlled trial of donepezil in patients with Alzheimer’s disease. Neurology 1998;50:136e45.
|
||||
Román GC, Tatemichi TK, Erkinjuntti T, Cummings JL, Masdeu JC, Garcia JH, et al. Vascular dementia: diagnostic criteria for research studies. Report of the NINDS-AIREN International Workshop. Neurology 1993;43:250e60.
|
||||
Schneider JA, Arvanitakis Z, Bang W, Bennett DA. Mixed brain pathologies account for most dementia cases in community-dwelling older persons. Neurology 2007;69:2197e204.
|
||||
Schneider LS, Olin JT, Doody RS, Clark CM, Morris JC, Reisberg B, et al. Validity and reliability of the Alzheimer’s disease cooperative study-clinical global impression of change. Alzheimer Disease and Associated Disorders 1997;11(Suppl. 2): S22e32.
|
||||
Schneider LS, DeKosky ST, Farlow MR, Tariot PN, Hoerr R, Kieser M. A randomized, double-blind, placebo-controlled trial of two doses of Ginkgo biloba extract in dementia of the Alzheimer’s type. Current Alzheimer Research 2005;2:541e51.
|
||||
Smith SC, Lamping DL, Banerjee S, Harwood R, Foley B, Smith P, et al. Measurement of health-related quality of life for people with dementia: development of a new instrument (DEMQOL) and an evaluation of current methodology. Health Technology Assessment 2005;9(No. 10).
|
||||
Steinberg M, Shao H, Zandi P, Lyketsos CG, Welsh-Bohmer KA, Norton MC, et al. Point and 5-year prevalence of neuropsychiatric symptoms in dementia: the Cache County Study. International Journal of Geriatric Psychiatry 2008;23: 170e7.
|
||||
Sunderland T, Hill J, Mellow A, Lawlor BA, Gundersheimer J, Newhouse PA, et al. Clock drawing in Alzheimer’s disease: a novel measure of dementia severity. Journal of the American Geriatrics Society 1989;37:725e9.
|
||||
Tchantchou F, Xu Y, Wu Y, Christen Y, Luo Y. EGb $7 6 1 ^ { \textregistered }$ enhances adult hippocampal neurogenesis and phosphorylation of CREB in transgenic mouse model of Alzheimer’s disease. FASEB Journal 2007;21:2400e8.
|
||||
Vellas B, Coley N, Ousset PJ, Berrut G, Dartigues JF, Dubois B, et al. Results of GuidAge e a 5-year placebo-controlled study of the efficacy on EGb $7 6 1 ^ { \textcircled { \times } } 1 2 0 \mathrm { m g }$ to prevent or delay Alzheimer’s dementia onset in elderly subjects with memory complaint. The Journal of Nutrition, Health and Aging 2010;14(Suppl. 2):S23.
|
||||
Verhey FR, Houx P, van Lang N, Huppert F, Stoppe G, Saerens J, et al. Cross-national comparison and validation of the Alzheimer’s disease assessment scale: results from the European harmonization project for instruments in dementia. International Journal of Geriatric Psychiatry 2004;19:41e50.
|
||||
Weinmann S, Roll S, Schwarzbach C, Vauth C, Willich SN. Effects of Ginkgo biloba in dementia: systematic review and meta-analysis. BMC Geriatrics 2010;10. Art 14.
|
||||
|
||||
Wimo A, Winblad B, Aguero-Torres H, von Strauss E. The magnitude of dementia occurrence in the world. Alzheimer Disease and Associated Disorders 2003;17: 63e7.
|
||||
Wu Y, Wu Z, Butko P, Christen Y, Lambert MP, Klein WL, et al. Amyloid- $\cdot \beta$ -induced pathological behaviors are suppressed by Ginkgo biloba extract EGb $7 6 1 ^ { \textcircled { \times } }$ and
|
||||
|
||||
ginkgolides in transgenic Caenorhabditis elegans. Journal of Neuroscience 2006; 26:13102e13.
|
||||
Yoshitake T, Yoshitake S, Kehr J. The Ginkgo biloba extract EGb $7 6 1 ^ { \textregistered }$ and its main constituent flavonoids and ginkgolides increase extracellular dopamine levels in the rat prefrontal cortex. British Journal of Pharmacology 2010;159:659e68.
|
||||
267
backend/tests/e2e-phase2-phase3-test.ts
Normal file
267
backend/tests/e2e-phase2-phase3-test.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Phase 2 + Phase 3 联合端到端测试
|
||||
*
|
||||
* 测试内容:
|
||||
* 1. 认证中间件(IIT admin 路由需要 Token)
|
||||
* 2. RBAC 角色权限(不同角色看到不同内容)
|
||||
* 3. /my-projects API(用户-项目关联)
|
||||
* 4. 租户过滤(项目按 tenantId 隔离)
|
||||
* 5. 租户选项 API
|
||||
* 6. 项目创建带 tenantId
|
||||
* 7. IIT_OPERATOR 角色枚举
|
||||
* 8. UserRole / TenantOption 类型完整性
|
||||
*
|
||||
* 运行方式: npx tsx tests/e2e-phase2-phase3-test.ts
|
||||
*/
|
||||
|
||||
const AUTH_BASE = 'http://localhost:3001/api/v1/auth';
|
||||
const ADMIN_IIT_BASE = 'http://localhost:3001/api/v1/admin/iit-projects';
|
||||
const IIT_BASE = 'http://localhost:3001/api/v1/iit';
|
||||
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
const results: { name: string; ok: boolean; detail?: string }[] = [];
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
async function rawFetch(url: string, opts?: RequestInit) {
|
||||
const res = await fetch(url, opts);
|
||||
const json = await res.json().catch(() => null);
|
||||
return { status: res.status, data: json };
|
||||
}
|
||||
|
||||
async function authApi(method: string, url: string, token: string, body?: any) {
|
||||
const opts: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
};
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
return rawFetch(url, opts);
|
||||
}
|
||||
|
||||
async function login(phone: string, password: string = '123456'): Promise<string | null> {
|
||||
const { status, data } = await rawFetch(`${AUTH_BASE}/login/password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phone, password }),
|
||||
});
|
||||
if (status === 200 && data?.data?.tokens?.accessToken) {
|
||||
return data.data.tokens.accessToken;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function assert(name: string, condition: boolean, detail?: string) {
|
||||
if (condition) {
|
||||
passCount++;
|
||||
results.push({ name, ok: true });
|
||||
console.log(` ✅ ${name}`);
|
||||
} else {
|
||||
failCount++;
|
||||
results.push({ name, ok: false, detail });
|
||||
console.log(` ❌ ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Test Suites ==========
|
||||
|
||||
async function testAuthMiddleware() {
|
||||
console.log('\n🔒 [1/8] IIT Admin 路由认证中间件');
|
||||
|
||||
// 无 Token 访问应返回 401
|
||||
const { status: noTokenStatus } = await rawFetch(`${ADMIN_IIT_BASE}`, { method: 'GET' });
|
||||
assert('无 Token 访问 GET /iit-projects → 401', noTokenStatus === 401, `status=${noTokenStatus}`);
|
||||
|
||||
const { status: noTokenStatus2 } = await rawFetch(`${ADMIN_IIT_BASE}/tenant-options`, { method: 'GET' });
|
||||
assert('无 Token 访问 GET /tenant-options → 401', noTokenStatus2 === 401, `status=${noTokenStatus2}`);
|
||||
}
|
||||
|
||||
async function testRoleForbidden() {
|
||||
console.log('\n🚫 [2/8] RBAC: 普通 USER 不能访问 IIT Admin');
|
||||
|
||||
const userToken = await login('13800138003'); // 王医生, role=USER
|
||||
assert('普通用户登录成功', userToken !== null);
|
||||
|
||||
if (userToken) {
|
||||
const { status } = await authApi('GET', ADMIN_IIT_BASE, userToken);
|
||||
assert('USER 角色访问 IIT Admin → 403', status === 403, `status=${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function testSuperAdminAccess() {
|
||||
console.log('\n👑 [3/8] SUPER_ADMIN 全权限访问');
|
||||
|
||||
const adminToken = await login('13800000001'); // 超级管理员
|
||||
assert('SUPER_ADMIN 登录成功', adminToken !== null);
|
||||
|
||||
if (adminToken) {
|
||||
const { status, data } = await authApi('GET', ADMIN_IIT_BASE, adminToken);
|
||||
assert('SUPER_ADMIN 访问项目列表 → 200', status === 200, `status=${status}`);
|
||||
assert('返回项目数组', Array.isArray(data?.data), `type=${typeof data?.data}`);
|
||||
|
||||
// 项目带 tenantName
|
||||
const projects = data?.data || [];
|
||||
if (projects.length > 0) {
|
||||
assert('项目含 tenantName 字段', projects[0].tenantName !== undefined,
|
||||
`keys=${Object.keys(projects[0]).join(',')}`);
|
||||
assert('项目含 tenantId 字段', projects[0].tenantId !== undefined);
|
||||
}
|
||||
}
|
||||
|
||||
return adminToken;
|
||||
}
|
||||
|
||||
async function testTenantOptions(adminToken: string) {
|
||||
console.log('\n🏢 [4/8] 租户选项 API');
|
||||
|
||||
const { status, data } = await authApi('GET', `${ADMIN_IIT_BASE}/tenant-options`, adminToken);
|
||||
assert('GET /tenant-options → 200', status === 200, `status=${status}`);
|
||||
assert('返回租户数组', Array.isArray(data?.data), `type=${typeof data?.data}`);
|
||||
|
||||
const tenants = data?.data || [];
|
||||
assert('租户数量 > 0', tenants.length > 0, `count=${tenants.length}`);
|
||||
|
||||
if (tenants.length > 0) {
|
||||
assert('租户含 id/name/code/type',
|
||||
tenants[0].id && tenants[0].name && tenants[0].code && tenants[0].type,
|
||||
`keys=${Object.keys(tenants[0]).join(',')}`);
|
||||
}
|
||||
|
||||
return tenants;
|
||||
}
|
||||
|
||||
async function testTenantFiltering(adminToken: string) {
|
||||
console.log('\n🔍 [5/8] 租户过滤');
|
||||
|
||||
// 壹证循科技 的 tenantId
|
||||
const yizhengxunId = 'eb4e93b7-0210-4bf5-b853-cbea49cdadf8';
|
||||
|
||||
// 不带租户过滤 → 返回所有项目
|
||||
const { data: allData } = await authApi('GET', ADMIN_IIT_BASE, adminToken);
|
||||
const allCount = allData?.data?.length || 0;
|
||||
assert('无过滤返回所有项目', allCount > 0, `count=${allCount}`);
|
||||
|
||||
// 带租户过滤 → 只返回该租户项目
|
||||
const { data: filteredData } = await authApi('GET',
|
||||
`${ADMIN_IIT_BASE}?tenantId=${yizhengxunId}`, adminToken);
|
||||
const filteredProjects = filteredData?.data || [];
|
||||
assert('按租户过滤返回结果', filteredProjects.length > 0, `count=${filteredProjects.length}`);
|
||||
assert('过滤结果中所有项目 tenantId 一致',
|
||||
filteredProjects.every((p: any) => p.tenantId === yizhengxunId),
|
||||
`tenantIds=${filteredProjects.map((p: any) => p.tenantId).join(',')}`);
|
||||
|
||||
// 不存在的租户 → 返回空数组
|
||||
const { data: emptyData } = await authApi('GET',
|
||||
`${ADMIN_IIT_BASE}?tenantId=non-existent-tenant`, adminToken);
|
||||
assert('不存在的租户返回空数组', (emptyData?.data?.length || 0) === 0);
|
||||
}
|
||||
|
||||
async function testPharmaAdminIsolation() {
|
||||
console.log('\n🏭 [6/8] PHARMA_ADMIN 租户隔离');
|
||||
|
||||
const pharmaToken = await login('13800000006'); // 药企管理员, tenant=takeda
|
||||
assert('PHARMA_ADMIN 登录成功', pharmaToken !== null);
|
||||
|
||||
if (pharmaToken) {
|
||||
const { status, data } = await authApi('GET', ADMIN_IIT_BASE, pharmaToken);
|
||||
assert('PHARMA_ADMIN 可以访问项目列表', status === 200, `status=${status}`);
|
||||
|
||||
const projects = data?.data || [];
|
||||
// PHARMA_ADMIN 的 tenantId 是 tenant-takeda,而测试项目属于 壹证循科技
|
||||
// 所以不应该看到测试项目
|
||||
const takedaTenantId = 'tenant-takeda';
|
||||
const allMatchTenant = projects.every((p: any) => p.tenantId === takedaTenantId);
|
||||
assert('PHARMA_ADMIN 只能看到自己租户的项目(或空)',
|
||||
projects.length === 0 || allMatchTenant,
|
||||
`count=${projects.length}, tenantIds=${projects.map((p: any) => p.tenantId).join(',')}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function testMyProjectsApi() {
|
||||
console.log('\n👤 [7/8] /my-projects API(用户-项目关联)');
|
||||
|
||||
// 无 Token → 401
|
||||
const { status: noTokenStatus } = await rawFetch(`${IIT_BASE}/my-projects`, { method: 'GET' });
|
||||
assert('无 Token 访问 /my-projects → 401', noTokenStatus === 401, `status=${noTokenStatus}`);
|
||||
|
||||
// 登录用户(有 IitUserMapping 关联)
|
||||
const adminToken = await login('13800000001');
|
||||
if (adminToken) {
|
||||
const { status, data } = await authApi('GET', `${IIT_BASE}/my-projects`, adminToken);
|
||||
assert('/my-projects 返回 200', status === 200, `status=${status}`);
|
||||
assert('返回 data 数组', Array.isArray(data?.data), `type=${typeof data?.data}`);
|
||||
|
||||
// 当前超级管理员可能没有 IitUserMapping,所以可能是空数组
|
||||
// 但 API 不应报错
|
||||
const projects = data?.data || [];
|
||||
assert('/my-projects 不报错(即使无关联项目)', true, `count=${projects.length}`);
|
||||
|
||||
if (projects.length > 0) {
|
||||
assert('项目含 myRole 字段', projects[0].myRole !== undefined,
|
||||
`keys=${Object.keys(projects[0]).join(',')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function testProjectDetailWithTenant(adminToken: string) {
|
||||
console.log('\n📋 [8/8] 项目详情含租户信息');
|
||||
|
||||
const projectId = 'test0102-pd-study';
|
||||
const { status, data } = await authApi('GET', `${ADMIN_IIT_BASE}/${projectId}`, adminToken);
|
||||
assert('GET 项目详情 → 200', status === 200, `status=${status}`);
|
||||
|
||||
const project = data?.data;
|
||||
assert('项目详情含 tenantId', project?.tenantId !== undefined && project?.tenantId !== null,
|
||||
`tenantId=${project?.tenantId}`);
|
||||
assert('项目详情含 tenant 对象', project?.tenant !== undefined,
|
||||
`tenant=${JSON.stringify(project?.tenant)}`);
|
||||
if (project?.tenant) {
|
||||
assert('tenant 含 name', !!project.tenant.name, `name=${project.tenant.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Main ==========
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 Phase 2 + Phase 3 联合端到端测试');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
try {
|
||||
// Phase 2 Tests
|
||||
await testAuthMiddleware();
|
||||
await testRoleForbidden();
|
||||
const adminToken = await testSuperAdminAccess();
|
||||
await testMyProjectsApi();
|
||||
|
||||
// Phase 3 Tests
|
||||
if (adminToken) {
|
||||
const tenants = await testTenantOptions(adminToken);
|
||||
await testTenantFiltering(adminToken);
|
||||
await testProjectDetailWithTenant(adminToken);
|
||||
}
|
||||
await testPharmaAdminIsolation();
|
||||
|
||||
} catch (err) {
|
||||
console.error('\n💥 测试执行异常:', err);
|
||||
failCount++;
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(`📊 测试结果: ${passCount} 通过, ${failCount} 失败, 共 ${passCount + failCount} 项`);
|
||||
|
||||
if (failCount > 0) {
|
||||
console.log('\n❌ 失败项:');
|
||||
results.filter(r => !r.ok).forEach(r => {
|
||||
console.log(` • ${r.name}${r.detail ? ` — ${r.detail}` : ''}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n' + (failCount === 0 ? '🎉 全部通过!' : '⚠️ 有失败项,请检查'));
|
||||
process.exit(failCount > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user