feat(iit): V3.2 data consistency + project isolation + admin config redesign + Chinese labels

Summary:
- Refactor timeline API to read from qc_field_status (SSOT) instead of qc_logs
- Add field-issues paginated API with severity/dimension/recordId filters
- Add LEFT JOIN field_metadata + qc_event_status for Chinese display names
- Implement per-project ChatOrchestrator cache and SessionMemory isolation
- Redesign admin IIT config tabs (REDCap -> Fields -> KB -> Rules -> Members)
- Add AI-powered QC rule generation (D3 programmatic + D1/D5/D6 LLM-based)
- Add clickable warning/critical detail Modal in ReportsPage
- Auto-dispatch eQuery after batch QC via DailyQcOrchestrator
- Update module status documentation to v3.2

Backend changes:
- iitQcCockpitController: rewrite getTimeline from qc_field_status, add getFieldIssues
- iitQcCockpitRoutes: add field-issues route
- ChatOrchestrator: per-projectId cached instances
- SessionMemory: keyed by userId::projectId
- WechatCallbackController: resolve projectId from iitUserMapping
- iitRuleSuggestionService: dimension-based suggest + generateD3Rules
- iitBatchController: call DailyQcOrchestrator after batch QC

Frontend changes:
- AiStreamPage: adapt to new timeline structure with dimension tags
- ReportsPage: clickable stats cards with issue detail Modal
- IitProjectDetailPage: reorder tabs, add AI rule generation UI
- iitProjectApi: add TimelineIssue, FieldIssueItem types and APIs

Status: TypeScript compilation verified, no new lint errors
Made-with: Cursor
This commit is contained in:
2026-03-02 14:29:59 +08:00
parent 72928d3116
commit 71d32d11ee
38 changed files with 1597 additions and 546 deletions

View File

@@ -40,7 +40,7 @@ async function main() {
console.log(` ✅ 内部租户创建成功: ${internalTenant.name}`);
// 为内部租户开放所有模块(超级管理员完整权限)
const internalModules = ['AIA', 'ASL', 'PKB', 'DC', 'SSA', 'ST', 'RVW', 'IIT'];
const internalModules = ['AIA', 'ASL', 'PKB', 'DC', 'SSA', 'ST', 'RVW', 'IIT', 'RM', 'AIA_PROTOCOL'];
for (const moduleCode of internalModules) {
await prisma.tenant_modules.upsert({
where: { tenant_id_module_code: { tenant_id: internalTenant.id, module_code: moduleCode } },

View File

@@ -1,4 +1,4 @@
你是一位专业的医学期刊编辑,负责评估稿件的规范性。你将严格按照中华医学超声杂志的稿约标准对稿件进行评估。
你是一位专业的医学期刊编辑,负责评估稿件的规范性。你将严格按照中华脑血管病杂志的稿约标准对稿件进行评估。
【你的职责】
1. 仔细阅读稿件的每个部分

View File

@@ -43,7 +43,7 @@ const MODULES = [
},
{
code: 'IIT',
name: 'IIT管理',
name: 'CRA质控',
description: 'IIT项目管理系统支持REDCap集成和项目协作',
icon: 'ProjectOutlined',
is_active: true,
@@ -73,6 +73,22 @@ const MODULES = [
is_active: true,
sort_order: 8,
},
{
code: 'RM',
name: '研究管理',
description: '研究项目管理系统,支持项目全流程管理',
icon: 'ProjectOutlined',
is_active: true,
sort_order: 9,
},
{
code: 'AIA_PROTOCOL',
name: '全流程研究方案制定',
description: 'AI问答模块内的Protocol Agent功能可按用户/租户独立配置开关',
icon: 'ExperimentOutlined',
is_active: true,
sort_order: 100,
},
];
async function main() {

View File

@@ -14,6 +14,7 @@ import { jwtService } from './jwt.service.js';
import type { DecodedToken } from './jwt.service.js';
import { logger } from '../logging/index.js';
import { moduleService } from './module.service.js';
import { cache } from '../cache/index.js';
/**
* 扩展 Fastify Request 类型
@@ -71,6 +72,15 @@ export const authenticate: preHandlerHookHandler = async (
// 2. 验证 Token
const decoded = jwtService.verifyToken(token);
// 2.5 验证 token 版本号(单设备登录:新登录会踢掉旧会话)
if (decoded.tokenVersion !== undefined) {
const tokenVersionKey = `token_version:${decoded.userId}`;
const currentVersion = await cache.get<number>(tokenVersionKey);
if (currentVersion !== null && decoded.tokenVersion < currentVersion) {
throw new AuthenticationError('您的账号已在其他设备登录,当前会话已失效');
}
}
// 3. 注入用户信息
request.user = decoded;

View File

@@ -13,6 +13,7 @@ import { prisma } from '../../config/database.js';
import { jwtService } from './jwt.service.js';
import type { JWTPayload, TokenResponse } from './jwt.service.js';
import { logger } from '../logging/index.js';
import { cache } from '../cache/index.js';
/**
* 登录请求 - 密码方式
@@ -115,7 +116,13 @@ export class AuthService {
const permissions = await this.getUserPermissions(user.role);
const modules = await this.getUserModules(user.id);
// 5. 生成 JWT
// 4.5 递增 token 版本号(实现单设备登录,踢掉旧会话)
const tokenVersionKey = `token_version:${user.id}`;
const currentVersion = await cache.get<number>(tokenVersionKey) || 0;
const newVersion = currentVersion + 1;
await cache.set(tokenVersionKey, newVersion, 30 * 24 * 60 * 60); // 30天有效
// 5. 生成 JWT包含 token 版本号)
const jwtPayload: JWTPayload = {
userId: user.id,
phone: user.phone,
@@ -123,6 +130,7 @@ export class AuthService {
tenantId: user.tenant_id,
tenantCode: user.tenants?.code,
isDefaultPassword: user.is_default_password,
tokenVersion: newVersion,
};
const tokens = jwtService.generateTokens(jwtPayload);
@@ -139,6 +147,7 @@ export class AuthService {
role: user.role,
tenantId: user.tenant_id,
modules: modules.length,
tokenVersion: newVersion,
});
return {
@@ -214,7 +223,13 @@ export class AuthService {
const permissions = await this.getUserPermissions(user.role);
const modules = await this.getUserModules(user.id);
// 6. 生成 JWT
// 5.5 递增 token 版本号(实现单设备登录,踢掉旧会话)
const tokenVersionKey = `token_version:${user.id}`;
const currentVersion = await cache.get<number>(tokenVersionKey) || 0;
const newVersion = currentVersion + 1;
await cache.set(tokenVersionKey, newVersion, 30 * 24 * 60 * 60);
// 6. 生成 JWT包含 token 版本号)
const jwtPayload: JWTPayload = {
userId: user.id,
phone: user.phone,
@@ -222,6 +237,7 @@ export class AuthService {
tenantId: user.tenant_id,
tenantCode: user.tenants?.code,
isDefaultPassword: user.is_default_password,
tokenVersion: newVersion,
};
const tokens = jwtService.generateTokens(jwtPayload);
@@ -231,6 +247,7 @@ export class AuthService {
phone: user.phone,
role: user.role,
modules: modules.length,
tokenVersion: newVersion,
});
return {
@@ -431,6 +448,10 @@ export class AuthService {
return null;
}
// 获取当前 token 版本号(单设备登录校验)
const tokenVersionKey = `token_version:${user.id}`;
const currentVersion = await cache.get<number>(tokenVersionKey) || 0;
return {
userId: user.id,
phone: user.phone,
@@ -438,6 +459,7 @@ export class AuthService {
tenantId: user.tenant_id,
tenantCode: user.tenants?.code,
isDefaultPassword: user.is_default_password,
tokenVersion: currentVersion,
};
});
}

View File

@@ -28,6 +28,8 @@ export interface JWTPayload {
tenantCode?: string;
/** 是否为默认密码 */
isDefaultPassword?: boolean;
/** Token版本号用于单点登录踢人 */
tokenVersion?: number;
}
/**
@@ -83,10 +85,10 @@ export class JWTService {
* 生成 Refresh Token
*/
generateRefreshToken(payload: JWTPayload): string {
// Refresh Token 只包含必要信息
const refreshPayload = {
userId: payload.userId,
type: 'refresh',
tokenVersion: payload.tokenVersion,
};
const options: SignOptions = {
@@ -144,12 +146,19 @@ export class JWTService {
throw new Error('无效的Refresh Token');
}
// 获取用户最新信息
// 获取用户最新信息(包含当前 tokenVersion
const user = await getUserById(decoded.userId);
if (!user) {
throw new Error('用户不存在');
}
// 验证 token 版本号(踢人检查)
const refreshTokenVersion = (decoded as any).tokenVersion;
if (refreshTokenVersion !== undefined && user.tokenVersion !== undefined
&& refreshTokenVersion < user.tokenVersion) {
throw new Error('您的账号已在其他设备登录,当前会话已失效');
}
// 生成新的 Tokens
return this.generateTokens(user);
}

View File

@@ -152,8 +152,52 @@ class ModuleService {
});
});
// 6. 合并所有模块(去重
const moduleSet = new Set(tenantModulesData.map(tm => tm.module_code));
// 5.5 查询用户级别的模块权限(精细化控制
const userModulesData = await prisma.user_modules.findMany({
where: {
user_id: userId,
tenant_id: { in: tenantIds },
},
select: {
tenant_id: true,
module_code: true,
is_enabled: true,
},
});
// 按租户分组 user_modules
const userModulesByTenant = new Map<string, Map<string, boolean>>();
for (const um of userModulesData) {
if (!userModulesByTenant.has(um.tenant_id)) {
userModulesByTenant.set(um.tenant_id, new Map());
}
userModulesByTenant.get(um.tenant_id)!.set(um.module_code, um.is_enabled);
}
// 6. 合并所有模块(去重),尊重 user_modules 精细化配置
const moduleSet = new Set<string>();
for (const tm of tenantModulesData) {
const userModulesForTenant = userModulesByTenant.get(tm.tenant_id);
if (userModulesForTenant && userModulesForTenant.size > 0) {
const isEnabled = userModulesForTenant.get(tm.module_code);
if (isEnabled) {
moduleSet.add(tm.module_code);
}
} else {
moduleSet.add(tm.module_code);
}
}
// 6.5 补充用户级独立配置的模块(如 AIA_PROTOCOL租户未订阅但用户单独开通
for (const [, userModuleMap] of userModulesByTenant) {
for (const [moduleCode, isEnabled] of userModuleMap) {
if (isEnabled) {
moduleSet.add(moduleCode);
}
}
}
const allModuleCodes = Array.from(moduleSet);
// 7. 获取模块详细信息

View File

@@ -19,6 +19,7 @@ import { RedcapAdapter } from '../../iit-manager/adapters/RedcapAdapter.js';
import { createSkillRunner } from '../../iit-manager/engines/SkillRunner.js';
import { QcExecutor } from '../../iit-manager/engines/QcExecutor.js';
import { QcReportService } from '../../iit-manager/services/QcReportService.js';
import { dailyQcOrchestrator } from '../../iit-manager/services/DailyQcOrchestrator.js';
const prisma = new PrismaClient();
@@ -63,12 +64,22 @@ export class IitBatchController {
const { totalRecords, totalEvents, passed, failed, warnings, fieldStatusWrites, executionTimeMs } = batchResult;
const passRate = totalEvents > 0 ? `${((passed / totalEvents) * 100).toFixed(1)}%` : '0%';
// 自动刷新 QcReport 缓存,使业务端立即看到最新数据
// 编排后续动作:生成报告 + 创建 eQuery + 归档关键事件 + 推送通知
let equeriesCreated = 0;
try {
await QcReportService.refreshReport(projectId);
logger.info('[V3.1] QcReport cache refreshed after batch QC', { projectId });
} catch (reportErr: any) {
logger.warn('[V3.1] QcReport refresh failed (non-blocking)', { projectId, error: reportErr.message });
const orchResult = await dailyQcOrchestrator.orchestrate(projectId);
equeriesCreated = orchResult.equeriesCreated;
logger.info('[V3.1] Orchestration completed after batch QC', {
projectId,
equeriesCreated: orchResult.equeriesCreated,
criticalEventsArchived: orchResult.criticalEventsArchived,
});
} catch (orchErr: any) {
logger.warn('[V3.1] Orchestration failed (non-blocking)', { projectId, error: orchErr.message });
// fallback: at least refresh report cache
try {
await QcReportService.refreshReport(projectId);
} catch { /* ignore */ }
}
const durationMs = Date.now() - startTime;
@@ -87,6 +98,7 @@ export class IitBatchController {
warnings,
fieldStatusWrites,
passRate,
equeriesCreated,
},
durationMs,
});

View File

@@ -189,7 +189,9 @@ class IitQcCockpitController {
}
/**
* 获取 AI 工作时间线(QC 日志 + Agent Trace 合并
* 获取 AI 工作时间线(从 qc_field_status 五级结构读取SSOT
*
* 按受试者分组,展示每个受试者的 FAIL/WARNING 问题列表。
*/
async getTimeline(
request: FastifyRequest<{
@@ -201,81 +203,168 @@ class IitQcCockpitController {
const { projectId } = request.params;
const query = request.query as any;
const page = query.page ? parseInt(query.page) : 1;
const pageSize = query.pageSize ? parseInt(query.pageSize) : 50;
const pageSize = query.pageSize ? parseInt(query.pageSize) : 20;
const dateFilter = query.date;
try {
const dateWhere: any = {};
let dateClause = '';
if (dateFilter) {
const start = new Date(dateFilter);
const end = new Date(dateFilter);
end.setDate(end.getDate() + 1);
dateWhere.createdAt = { gte: start, lt: end };
dateClause = `AND fs.last_qc_at >= '${dateFilter}'::date AND fs.last_qc_at < ('${dateFilter}'::date + INTERVAL '1 day')`;
}
const [qcLogs, totalLogs] = await Promise.all([
prisma.iitQcLog.findMany({
where: { projectId, ...dateWhere },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
recordId: true,
eventId: true,
qcType: true,
formName: true,
status: true,
issues: true,
rulesEvaluated: true,
rulesPassed: true,
rulesFailed: true,
triggeredBy: true,
createdAt: true,
},
}),
prisma.iitQcLog.count({ where: { projectId, ...dateWhere } }),
]);
// 1. 获取有问题的受试者摘要(分页)
const recordSummaries = await prisma.$queryRawUnsafe<Array<{
record_id: string;
critical_count: bigint;
warning_count: bigint;
total_issues: bigint;
latest_qc_at: Date;
triggered_by: string;
}>>(
`SELECT
fs.record_id,
COUNT(*) FILTER (WHERE fs.severity = 'critical') AS critical_count,
COUNT(*) FILTER (WHERE fs.severity != 'critical') AS warning_count,
COUNT(*) AS total_issues,
MAX(fs.last_qc_at) AS latest_qc_at,
MAX(fs.triggered_by) AS triggered_by
FROM iit_schema.qc_field_status fs
WHERE fs.project_id = $1 AND fs.status IN ('FAIL', 'WARNING')
${dateClause}
GROUP BY fs.record_id
ORDER BY MAX(fs.last_qc_at) DESC
LIMIT $2 OFFSET $3`,
projectId, pageSize, (page - 1) * pageSize
);
const items = qcLogs.map((log) => {
const rawIssues = log.issues as any;
const issues: any[] = Array.isArray(rawIssues) ? rawIssues : (rawIssues?.items || []);
const redCount = issues.filter((i: any) => i.severity === 'critical' || i.level === 'RED').length;
const yellowCount = issues.filter((i: any) => i.severity === 'warning' || i.level === 'YELLOW').length;
const eventLabel = rawIssues?.eventLabel || '';
const totalRules = rawIssues?.summary?.totalRules || log.rulesEvaluated || 0;
// 2. 总受试者数
const countResult = await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>(
`SELECT COUNT(DISTINCT record_id) AS cnt
FROM iit_schema.qc_field_status
WHERE project_id = $1 AND status IN ('FAIL', 'WARNING')
${dateClause}`,
projectId
);
const totalRecords = Number(countResult[0]?.cnt || 0);
let description = `扫描受试者 ${log.recordId}`;
if (eventLabel) description += `${eventLabel}`;
description += ` → 执行 ${totalRules} 条规则 (${log.rulesPassed} 通过`;
if (log.rulesFailed > 0) description += `, ${log.rulesFailed} 失败`;
description += ')';
if (redCount > 0) description += ` → 发现 ${redCount} 个严重问题`;
if (yellowCount > 0) description += `, ${yellowCount} 个警告`;
// 3. 获取这些受试者的问题详情LEFT JOIN 获取字段/事件中文名)
const recordIds = recordSummaries.map(r => r.record_id);
let issues: any[] = [];
if (recordIds.length > 0) {
issues = await prisma.$queryRawUnsafe<any[]>(
`SELECT
fs.record_id, fs.event_id, fs.form_name, fs.field_name,
fm.field_label,
es.event_label,
fs.rule_category, fs.rule_name, fs.rule_id,
fs.severity, fs.status, fs.message,
fs.actual_value, fs.expected_value, fs.last_qc_at
FROM iit_schema.qc_field_status fs
LEFT JOIN iit_schema.field_metadata fm
ON fm.project_id = fs.project_id AND fm.field_name = fs.field_name
LEFT JOIN iit_schema.qc_event_status es
ON es.project_id = fs.project_id AND es.record_id = fs.record_id AND es.event_id = fs.event_id
WHERE fs.project_id = $1
AND fs.status IN ('FAIL', 'WARNING')
AND fs.record_id = ANY($2)
ORDER BY fs.record_id, fs.last_qc_at DESC`,
projectId, recordIds
);
}
// 4. 组装成按受试者分组的 timeline items
const issuesByRecord = new Map<string, any[]>();
for (const issue of issues) {
const key = issue.record_id;
if (!issuesByRecord.has(key)) issuesByRecord.set(key, []);
issuesByRecord.get(key)!.push(issue);
}
// 5. 同时获取通过的受试者(无问题的),补充到时间线
const passedRecords = await prisma.$queryRawUnsafe<Array<{
record_id: string;
total_fields: bigint;
latest_qc_at: Date;
triggered_by: string;
}>>(
`SELECT
fs.record_id,
COUNT(*) AS total_fields,
MAX(fs.last_qc_at) AS latest_qc_at,
MAX(fs.triggered_by) AS triggered_by
FROM iit_schema.qc_field_status fs
WHERE fs.project_id = $1
AND fs.record_id NOT IN (
SELECT DISTINCT record_id FROM iit_schema.qc_field_status
WHERE project_id = $1 AND status IN ('FAIL', 'WARNING')
)
${dateClause}
GROUP BY fs.record_id
ORDER BY MAX(fs.last_qc_at) DESC
LIMIT 10`,
projectId
);
const items = recordSummaries.map(rec => {
const recIssues = issuesByRecord.get(rec.record_id) || [];
const criticalCount = Number(rec.critical_count);
const warningCount = Number(rec.warning_count);
const status = criticalCount > 0 ? 'FAIL' : 'WARNING';
return {
id: log.id,
id: `fs_${rec.record_id}`,
type: 'qc_check' as const,
time: log.createdAt,
recordId: log.recordId,
eventLabel,
formName: log.formName,
status: log.status,
triggeredBy: log.triggeredBy,
description,
time: rec.latest_qc_at,
recordId: rec.record_id,
status,
triggeredBy: rec.triggered_by || 'batch',
description: `受试者 ${rec.record_id} 发现 ${criticalCount + warningCount} 个问题`,
details: {
rulesEvaluated: totalRules,
rulesPassed: log.rulesPassed,
rulesFailed: log.rulesFailed,
issuesSummary: { red: redCount, yellow: yellowCount },
issues,
issuesSummary: { red: criticalCount, yellow: warningCount },
issues: recIssues.map((i: any) => ({
ruleId: i.rule_id || '',
ruleName: i.rule_name || '',
ruleCategory: i.rule_category || '',
field: i.field_name || '',
fieldLabel: i.field_label || '',
eventId: i.event_id || '',
eventLabel: i.event_label || '',
formName: i.form_name || '',
message: i.message || '',
severity: i.severity || 'warning',
actualValue: i.actual_value,
expectedValue: i.expected_value,
})),
},
};
});
// 追加通过的受试者
for (const rec of passedRecords) {
items.push({
id: `fs_pass_${rec.record_id}`,
type: 'qc_check' as const,
time: rec.latest_qc_at,
recordId: rec.record_id,
status: 'PASS',
triggeredBy: rec.triggered_by || 'batch',
description: `受试者 ${rec.record_id} 全部通过 (${Number(rec.total_fields)} 个字段)`,
details: {
issuesSummary: { red: 0, yellow: 0 },
issues: [],
},
});
}
// 按时间降序排序
items.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
// 6. 总数 = 有问题 + 通过
const totalAll = totalRecords + passedRecords.length;
return reply.send({
success: true,
data: { items, total: totalLogs, page, pageSize },
data: { items, total: totalAll, page, pageSize },
});
} catch (error: any) {
logger.error('[QcCockpitController] 获取时间线失败', { projectId, error: error.message });
@@ -493,6 +582,129 @@ class IitQcCockpitController {
}
}
/**
* 字段级问题分页查询(从 qc_field_status SSOT
* 支持按 severity / dimension / recordId 筛选
*/
async getFieldIssues(
request: FastifyRequest<{
Params: { projectId: string };
Querystring: { page?: string; pageSize?: string; severity?: string; dimension?: string; recordId?: string };
}>,
reply: FastifyReply
) {
const { projectId } = request.params;
const query = request.query as any;
const page = query.page ? parseInt(query.page) : 1;
const pageSize = query.pageSize ? parseInt(query.pageSize) : 50;
const severity = query.severity; // 'critical' | 'warning'
const dimension = query.dimension; // 'D1' | 'D3' | ...
const recordId = query.recordId;
try {
const conditions: string[] = [`fs.project_id = $1`, `fs.status IN ('FAIL', 'WARNING')`];
const params: any[] = [projectId];
let paramIdx = 2;
if (severity) {
conditions.push(`fs.severity = $${paramIdx}`);
params.push(severity);
paramIdx++;
}
if (dimension) {
conditions.push(`fs.rule_category = $${paramIdx}`);
params.push(dimension);
paramIdx++;
}
if (recordId) {
conditions.push(`fs.record_id = $${paramIdx}`);
params.push(recordId);
paramIdx++;
}
const whereClause = conditions.join(' AND ');
const [rows, countResult] = await Promise.all([
prisma.$queryRawUnsafe<any[]>(
`SELECT
fs.id, fs.record_id, fs.event_id, fs.form_name, fs.field_name,
fm.field_label,
es.event_label,
fs.rule_category, fs.rule_name, fs.rule_id,
fs.severity, fs.status, fs.message,
fs.actual_value, fs.expected_value, fs.last_qc_at
FROM iit_schema.qc_field_status fs
LEFT JOIN iit_schema.field_metadata fm
ON fm.project_id = fs.project_id AND fm.field_name = fs.field_name
LEFT JOIN iit_schema.qc_event_status es
ON es.project_id = fs.project_id AND es.record_id = fs.record_id AND es.event_id = fs.event_id
WHERE ${whereClause}
ORDER BY fs.last_qc_at DESC
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`,
...params, pageSize, (page - 1) * pageSize
),
prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>(
`SELECT COUNT(*) AS cnt FROM iit_schema.qc_field_status fs WHERE ${whereClause}`,
...params
),
]);
const total = Number(countResult[0]?.cnt || 0);
const items = rows.map((r: any) => ({
id: r.id,
recordId: r.record_id,
eventId: r.event_id,
eventLabel: r.event_label || '',
formName: r.form_name,
fieldName: r.field_name,
fieldLabel: r.field_label || '',
ruleCategory: r.rule_category,
ruleName: r.rule_name,
ruleId: r.rule_id,
severity: r.severity,
status: r.status,
message: r.message,
actualValue: r.actual_value,
expectedValue: r.expected_value,
lastQcAt: r.last_qc_at,
}));
// 聚合统计
const summaryResult = await prisma.$queryRawUnsafe<Array<{
severity: string;
rule_category: string;
cnt: bigint;
}>>(
`SELECT fs.severity, COALESCE(fs.rule_category, 'OTHER') AS rule_category, COUNT(*) AS cnt
FROM iit_schema.qc_field_status fs
WHERE fs.project_id = $1 AND fs.status IN ('FAIL', 'WARNING')
GROUP BY fs.severity, fs.rule_category`,
projectId
);
const summary = {
totalIssues: total,
bySeverity: { critical: 0, warning: 0, info: 0 } as Record<string, number>,
byDimension: {} as Record<string, number>,
};
for (const row of summaryResult) {
const sev = row.severity || 'warning';
summary.bySeverity[sev] = (summary.bySeverity[sev] || 0) + Number(row.cnt);
const dim = row.rule_category || 'OTHER';
summary.byDimension[dim] = (summary.byDimension[dim] || 0) + Number(row.cnt);
}
return reply.send({
success: true,
data: { items, total, page, pageSize, summary },
});
} catch (error: any) {
logger.error('[QcCockpitController] getFieldIssues failed', { projectId, error: error.message });
return reply.status(500).send({ success: false, error: error.message });
}
}
// ============================================================
// GCP 业务报表 API
// ============================================================

View File

@@ -292,6 +292,25 @@ export async function iitQcCockpitRoutes(fastify: FastifyInstance) {
// V3.1: D6 方案偏离列表
fastify.get('/:projectId/qc-cockpit/deviations', iitQcCockpitController.getDeviations.bind(iitQcCockpitController));
// 字段级问题分页查询(支持按维度/严重程度筛选)
fastify.get('/:projectId/qc-cockpit/field-issues', {
schema: {
description: '从 qc_field_status 分页查询所有问题字段',
tags: ['IIT Admin - QC 驾驶舱'],
params: { type: 'object', properties: { projectId: { type: 'string' } }, required: ['projectId'] },
querystring: {
type: 'object',
properties: {
page: { type: 'string' },
pageSize: { type: 'string' },
severity: { type: 'string' },
dimension: { type: 'string' },
recordId: { type: 'string' },
},
},
},
}, iitQcCockpitController.getFieldIssues.bind(iitQcCockpitController));
// ============================================================
// GCP 业务报表路由
// ============================================================

View File

@@ -289,16 +289,20 @@ export async function getRuleStats(
}
/**
* AI 规则建议
* AI 规则建议(支持按维度生成)
*/
export async function suggestRules(
request: FastifyRequest<{ Params: ProjectIdParams }>,
request: FastifyRequest<{ Params: ProjectIdParams; Querystring: { dimension?: string } }>,
reply: FastifyReply
) {
try {
const { projectId } = request.params;
const dimension = (request.query as any)?.dimension as string | undefined;
const validDimensions = ['D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7'];
const dim = dimension && validDimensions.includes(dimension) ? dimension as any : undefined;
const service = getIitRuleSuggestionService(prisma);
const suggestions = await service.suggestRules(projectId);
const suggestions = await service.suggestRules(projectId, dim);
return reply.send({
success: true,
@@ -306,7 +310,33 @@ export async function suggestRules(
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error('AI 规则建议生成失败', { error: message });
logger.error('AI 规则建议生成失败', { error: message, dimension: (request.query as any)?.dimension });
return reply.status(500).send({
success: false,
error: message,
});
}
}
/**
* D3 规则自动生成(数据驱动,无需 LLM
*/
export async function generateD3Rules(
request: FastifyRequest<{ Params: ProjectIdParams }>,
reply: FastifyReply
) {
try {
const { projectId } = request.params;
const service = getIitRuleSuggestionService(prisma);
const rules = await service.generateD3Rules(projectId);
return reply.send({
success: true,
data: rules,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error('D3 规则自动生成失败', { error: message });
return reply.status(500).send({
success: false,
error: message,

View File

@@ -27,9 +27,12 @@ export async function iitQcRuleRoutes(fastify: FastifyInstance) {
// 批量导入规则
fastify.post('/:projectId/rules/import', controller.importRules);
// AI 规则建议
// AI 规则建议(支持 ?dimension=D1 查询参数)
fastify.post('/:projectId/rules/suggest', controller.suggestRules);
// D3 规则自动生成(数据驱动,无需 LLM
fastify.post('/:projectId/rules/generate-d3', controller.generateD3Rules);
// 测试规则逻辑(不需要项目 ID
fastify.post('/rules/test', controller.testRule);
}

View File

@@ -171,14 +171,17 @@ export class IitQcRuleService {
async importRules(projectId: string, rules: CreateRuleInput[]): Promise<QCRule[]> {
const skill = await this.getOrCreateSkill(projectId);
const existingConfig = (skill.config as unknown as QCRuleConfig) || { rules: [], version: 1, updatedAt: '' };
const existingRules = Array.isArray(existingConfig.rules) ? existingConfig.rules : [];
const newRules: QCRule[] = rules.map((input, index) => ({
id: `rule_${Date.now()}_${index}_${Math.random().toString(36).substr(2, 9)}`,
...input,
}));
const config: QCRuleConfig = {
rules: newRules,
version: 1,
rules: [...existingRules, ...newRules],
version: (existingConfig.version || 0) + 1,
updatedAt: new Date().toISOString(),
};

View File

@@ -2,6 +2,7 @@
* AI 规则建议服务
*
* 读取项目的变量元数据和知识库文档,调用 LLM 生成质控规则建议。
* 支持按维度(D1-D7)生成,以及纯数据驱动的 D3 规则自动构建。
*/
import { PrismaClient } from '@prisma/client';
@@ -9,6 +10,8 @@ import { LLMFactory } from '../../../common/llm/adapters/LLMFactory.js';
import type { Message } from '../../../common/llm/adapters/types.js';
import { logger } from '../../../common/logging/index.js';
export type DimensionCode = 'D1' | 'D2' | 'D3' | 'D4' | 'D5' | 'D6' | 'D7';
export interface RuleSuggestion {
name: string;
field: string | string[];
@@ -16,18 +19,86 @@ export interface RuleSuggestion {
message: string;
severity: 'error' | 'warning' | 'info';
category: string;
applicableEvents?: string[];
}
const DIMENSION_META: Record<DimensionCode, { label: string; description: string; needsKb: boolean }> = {
D1: { label: '入选/排除', description: '受试者纳入标准和排除标准的合规性检查', needsKb: true },
D2: { label: '完整性', description: '必填字段、缺失数据、表单完成度检查', needsKb: false },
D3: { label: '准确性', description: '数值范围、枚举值、数据格式准确性检查', needsKb: false },
D4: { label: '质疑管理', description: 'Query 响应时限和关闭状态检查', needsKb: false },
D5: { label: '安全性', description: '不良事件(AE/SAE)报告时限和完整性检查', needsKb: true },
D6: { label: '方案偏离', description: '访视窗口期、用药方案合规性检查', needsKb: true },
D7: { label: '药物管理', description: '试验药物接收、分配、回收记录的完整性检查', needsKb: true },
};
function buildDimensionPrompt(dimension: DimensionCode): string {
switch (dimension) {
case 'D1':
return `Focus EXCLUSIVELY on D1 (Eligibility) rules:
- Inclusion criteria: age range, gender, diagnosis, consent date
- Exclusion criteria: contraindicated conditions, prior treatments, lab exclusions
- Generate rules that verify each inclusion criterion is met and no exclusion criterion is triggered
- Use fields related to: demographics, medical_history, consent, eligibility forms`;
case 'D2':
return `Focus EXCLUSIVELY on D2 (Completeness) rules:
- Required field checks: key CRF fields that must not be empty
- Form completion checks: ensure critical forms have data
- Missing data detection for safety-critical fields
- Use "!!" or "missing" operators to check field presence`;
case 'D3':
return `Focus EXCLUSIVELY on D3 (Accuracy) rules:
- Numeric range checks: vital signs, lab values, dosing
- Enum/choice validation: field values within allowed options
- Date logic: visit date order, date format validity
- Cross-field consistency: e.g. BMI matches height/weight`;
case 'D5':
return `Focus EXCLUSIVELY on D5 (Safety/AE) rules:
- AE onset date must be after informed consent date
- SAE must be reported within 24 hours (if reporting date available)
- AE severity and outcome fields must be complete when AE is present
- Relationship to study drug must be documented`;
case 'D6':
return `Focus EXCLUSIVELY on D6 (Protocol Deviation) rules:
- Visit window checks: actual visit date within allowed window of scheduled date
- Dose modification rules: dose changes must have documented reason
- Prohibited concomitant medication checks
- Procedure timing compliance`;
case 'D7':
return `Focus EXCLUSIVELY on D7 (Drug Management) rules:
- Drug dispensing records completeness
- Drug accountability: dispensed vs returned quantities
- Storage temperature compliance (if tracked)
- Drug expiry date checks`;
case 'D4':
return `Focus EXCLUSIVELY on D4 (Query Management) rules:
- Data discrepancy auto-detection
- Cross-form consistency checks that would generate queries
- Logic contradiction checks between related fields`;
default:
return '';
}
}
export class IitRuleSuggestionService {
constructor(private prisma: PrismaClient) {}
async suggestRules(projectId: string): Promise<RuleSuggestion[]> {
/**
* AI 生成规则建议(按维度)
*/
async suggestRules(projectId: string, dimension?: DimensionCode): Promise<RuleSuggestion[]> {
const project = await this.prisma.iitProject.findFirst({
where: { id: projectId, deletedAt: null },
});
if (!project) throw new Error('项目不存在');
// 1. Gather variable metadata
const fields = await this.prisma.iitFieldMetadata.findMany({
where: { projectId },
orderBy: [{ formName: 'asc' }, { fieldName: 'asc' }],
@@ -37,9 +108,11 @@ export class IitRuleSuggestionService {
throw new Error('请先从 REDCap 同步变量元数据');
}
// 2. Gather knowledge base context (protocol summary)
let protocolContext = '';
if (project.knowledgeBaseId) {
const dimMeta = dimension ? DIMENSION_META[dimension] : null;
const needsKb = dimMeta ? dimMeta.needsKb : true;
if (needsKb && project.knowledgeBaseId) {
try {
const docs = await this.prisma.ekbDocument.findMany({
where: { kbId: project.knowledgeBaseId, status: 'completed' },
@@ -58,7 +131,6 @@ export class IitRuleSuggestionService {
}
}
// 3. Build variable summary for LLM
const variableSummary = fields.map((f) => {
const parts = [`${f.fieldName} (${f.fieldLabel}): type=${f.fieldType}, form=${f.formName}`];
if (f.validation) parts.push(`validation=${f.validation}`);
@@ -68,35 +140,31 @@ export class IitRuleSuggestionService {
return parts.join(', ');
}).join('\n');
// 4. Call LLM
const systemPrompt = `You are an expert clinical research data manager. You generate quality control (QC) rules for clinical trial data captured in REDCap.
const dimensionList = Object.entries(DIMENSION_META)
.map(([code, meta]) => `- ${code}: ${meta.label}${meta.description}`)
.join('\n');
Rules must be in JSON Logic format (https://jsonlogic.com). Each rule checks one or more fields.
const dimensionInstruction = dimension
? `\n\n*** IMPORTANT: ${buildDimensionPrompt(dimension)} ***\nAll generated rules MUST have category="${dimension}".`
: '';
Available categories:
- variable_qc: Field-level checks (range, required, format, enum)
- inclusion: Inclusion criteria checks
- exclusion: Exclusion criteria checks
- lab_values: Lab value range checks
- logic_check: Cross-field logic checks
- protocol_deviation: Visit window / time constraint checks
- ae_monitoring: AE reporting timeline checks
const systemPrompt = `You are an expert clinical research data manager (GCP-trained). Generate QC rules in JSON Logic format for clinical trial data from REDCap.
Severity levels: error (blocking), warning (review needed), info (informational)
Available dimension categories (use these as the "category" field):
${dimensionList}
Respond ONLY with a JSON array of rule objects. Each object must have these fields:
Severity levels: error (blocking issue), warning (needs review), info (informational)
${dimensionInstruction}
Respond ONLY with a JSON array. Each object:
- name (string): short descriptive name in Chinese
- field (string or string[]): REDCap field name(s)
- logic (object): JSON Logic expression
- message (string): error message in Chinese
- severity: "error" | "warning" | "info"
- category: one of the categories listed above
- field (string or string[]): REDCap field name(s) — must match actual variable names from the list
- logic (object): JSON Logic expression using these field names as {"var": "fieldName"}
- message (string): error/warning message in Chinese
- severity: "error" | "warning" | "info"
- category: one of D1-D7
Generate 5-10 practical rules. Focus on:
1. Required field checks for key variables
2. Range checks for numeric fields that have validation ranges
3. Logical consistency checks between related fields
4. Date field checks (visit windows, timelines)
Generate 5-10 practical, accurate rules. Do NOT invent field names — only use fields from the provided variable list.
Do NOT include explanations, only the JSON array.`;
const userPrompt = `Project: ${project.name}
@@ -105,7 +173,7 @@ Variable List (${fields.length} fields):
${variableSummary}
${protocolContext ? `\nProtocol / Study Document Context:\n${protocolContext}` : ''}
Generate QC rules for this project:`;
Generate ${dimension ? `${dimension} (${dimMeta!.label})` : 'QC'} rules for this project:`;
const messages: Message[] = [
{ role: 'system', content: systemPrompt },
@@ -120,7 +188,6 @@ Generate QC rules for this project:`;
});
const content = (response.content ?? '').trim();
// Extract JSON array from response (handle markdown code fences)
const jsonMatch = content.match(/\[[\s\S]*\]/);
if (!jsonMatch) {
logger.error('LLM 返回非 JSON 格式', { content: content.substring(0, 200) });
@@ -129,13 +196,13 @@ Generate QC rules for this project:`;
const rules: RuleSuggestion[] = JSON.parse(jsonMatch[0]);
// Validate structure
const validRules = rules.filter(
(r) => r.name && r.field && r.logic && r.message && r.severity && r.category
);
logger.info('AI 规则建议生成成功', {
projectId,
dimension: dimension || 'all',
total: rules.length,
valid: validRules.length,
model: response.model,
@@ -149,6 +216,90 @@ Generate QC rules for this project:`;
throw err;
}
}
/**
* 数据驱动的 D3准确性规则自动生成 — 无需 LLM
*/
async generateD3Rules(projectId: string): Promise<RuleSuggestion[]> {
const fields = await this.prisma.iitFieldMetadata.findMany({
where: { projectId },
orderBy: [{ formName: 'asc' }, { fieldName: 'asc' }],
});
if (fields.length === 0) {
throw new Error('请先从 REDCap 同步变量元数据');
}
const rules: RuleSuggestion[] = [];
for (const f of fields) {
if (f.fieldType === 'descriptive' || f.fieldType === 'section_header') continue;
const hasMin = f.validationMin !== null && f.validationMin !== '';
const hasMax = f.validationMax !== null && f.validationMax !== '';
if (hasMin || hasMax) {
const logic: Record<string, unknown>[] = [];
const label = f.fieldLabel || f.fieldName;
const parts: string[] = [];
if (hasMin) {
const min = Number(f.validationMin);
if (!isNaN(min)) {
logic.push({ '>=': [{ 'var': f.fieldName }, min] });
parts.push(`${min}`);
}
}
if (hasMax) {
const max = Number(f.validationMax);
if (!isNaN(max)) {
logic.push({ '<=': [{ 'var': f.fieldName }, max] });
parts.push(`${max}`);
}
}
if (logic.length > 0) {
const finalLogic = logic.length === 1 ? logic[0] : { and: logic };
rules.push({
name: `${label} 范围检查`,
field: f.fieldName,
logic: finalLogic,
message: `${label} 应在 ${parts.join(' 且 ')} 范围内`,
severity: 'warning',
category: 'D3',
});
}
}
if (f.choices && (f.fieldType === 'radio' || f.fieldType === 'dropdown')) {
const choicePairs = f.choices.split('|').map(c => c.trim());
const validValues = choicePairs
.map(pair => {
const sep = pair.indexOf(',');
return sep > -1 ? pair.substring(0, sep).trim() : pair.trim();
})
.filter(v => v !== '');
if (validValues.length > 0) {
rules.push({
name: `${f.fieldLabel || f.fieldName} 有效值检查`,
field: f.fieldName,
logic: { 'in': [{ 'var': f.fieldName }, validValues] },
message: `${f.fieldLabel || f.fieldName} 取值必须是 [${validValues.join(', ')}] 之一`,
severity: 'warning',
category: 'D3',
});
}
}
}
logger.info('D3 规则自动生成完成', {
projectId,
totalFields: fields.length,
rulesGenerated: rules.length,
});
return rules;
}
}
let serviceInstance: IitRuleSuggestionService | null = null;

View File

@@ -677,16 +677,15 @@ export async function updateUserModules(
throw new Error('用户不是该租户的成员');
}
// 获取租户订阅的模块
const tenantModules = await prisma.tenant_modules.findMany({
where: { tenant_id: data.tenantId, is_enabled: true },
// 验证请求的模块是否在系统模块表中存在
const allModules = await prisma.modules.findMany({
where: { is_active: true },
select: { code: true },
});
const tenantModuleCodes = tenantModules.map((tm) => tm.module_code);
// 验证请求的模块是否在租户订阅范围内
const invalidModules = data.modules.filter((m) => !tenantModuleCodes.includes(m));
const validModuleCodes = allModules.map((m) => m.code);
const invalidModules = data.modules.filter((m) => !validModuleCodes.includes(m));
if (invalidModules.length > 0) {
throw new Error(`以下模块不在租户订阅范围内: ${invalidModules.join(', ')}`);
throw new Error(`以下模块代码不存在: ${invalidModules.join(', ')}`);
}
// 更新用户模块权限
@@ -878,10 +877,12 @@ function getModuleName(code: string): string {
PKB: '个人知识库',
ASL: 'AI智能文献',
DC: '数据清洗整理',
IIT: 'IIT Manager',
IIT: 'CRA质控',
RVW: '稿件审查',
SSA: '智能统计分析',
ST: '统计分析工具',
RM: '研究管理',
AIA_PROTOCOL: '全流程研究方案制定',
};
return moduleNames[code] || code;
}

View File

@@ -42,37 +42,42 @@ export class SessionMemory {
private readonly MAX_HISTORY = 3; // 只保留最近3轮6条消息
private readonly SESSION_TIMEOUT = 3600000; // 1小时毫秒
private sessionKey(userId: string, projectId?: string): string {
return projectId ? `${userId}::${projectId}` : userId;
}
/**
* 添加对话记录
*/
addMessage(userId: string, role: 'user' | 'assistant', content: string): void {
if (!this.sessions.has(userId)) {
this.sessions.set(userId, {
addMessage(userId: string, role: 'user' | 'assistant', content: string, projectId?: string): void {
const key = this.sessionKey(userId, projectId);
if (!this.sessions.has(key)) {
this.sessions.set(key, {
userId,
messages: [],
createdAt: new Date(),
updatedAt: new Date(),
});
logger.debug('[SessionMemory] 创建新会话', { userId });
logger.debug('[SessionMemory] 创建新会话', { userId, projectId });
}
const session = this.sessions.get(userId)!;
const session = this.sessions.get(key)!;
session.messages.push({
role,
content,
timestamp: new Date(),
});
// 只保留最近3轮6条消息3个user + 3个assistant
if (session.messages.length > this.MAX_HISTORY * 2) {
const removed = session.messages.length - this.MAX_HISTORY * 2;
session.messages = session.messages.slice(-this.MAX_HISTORY * 2);
logger.debug('[SessionMemory] 清理历史消息', { userId, removedCount: removed });
logger.debug('[SessionMemory] 清理历史消息', { userId, projectId, removedCount: removed });
}
session.updatedAt = new Date();
logger.debug('[SessionMemory] 添加消息', {
userId,
projectId,
role,
messageLength: content.length,
totalMessages: session.messages.length,
@@ -82,13 +87,13 @@ export class SessionMemory {
/**
* 获取用户对话历史最近N轮
*/
getHistory(userId: string, maxTurns: number = 3): ConversationMessage[] {
const session = this.sessions.get(userId);
getHistory(userId: string, maxTurns: number = 3, projectId?: string): ConversationMessage[] {
const key = this.sessionKey(userId, projectId);
const session = this.sessions.get(key);
if (!session) {
return [];
}
// 返回最近N轮2N条消息
const maxMessages = maxTurns * 2;
return session.messages.length > maxMessages
? session.messages.slice(-maxMessages)
@@ -98,8 +103,8 @@ export class SessionMemory {
/**
* 获取用户上下文格式化为字符串用于LLM Prompt
*/
getContext(userId: string): string {
const history = this.getHistory(userId, 2); // 只取最近2轮
getContext(userId: string, projectId?: string): string {
const history = this.getHistory(userId, 2, projectId);
if (history.length === 0) {
return '';
}
@@ -112,10 +117,11 @@ export class SessionMemory {
/**
* 清除用户会话
*/
clearSession(userId: string): void {
const existed = this.sessions.delete(userId);
clearSession(userId: string, projectId?: string): void {
const key = this.sessionKey(userId, projectId);
const existed = this.sessions.delete(key);
if (existed) {
logger.info('[SessionMemory] 清除会话', { userId });
logger.info('[SessionMemory] 清除会话', { userId, projectId });
}
}

View File

@@ -22,7 +22,7 @@ import { PrismaClient } from '@prisma/client';
import { createRequire } from 'module';
import { logger } from '../../../common/logging/index.js';
import { wechatService } from '../services/WechatService.js';
import { ChatOrchestrator, getChatOrchestrator } from '../services/ChatOrchestrator.js';
import { getChatOrchestrator } from '../services/ChatOrchestrator.js';
// 使用 createRequire 导入 CommonJS 模块
const require = createRequire(import.meta.url);
@@ -75,7 +75,7 @@ export class WechatCallbackController {
private token: string;
private encodingAESKey: string;
private corpId: string;
private chatOrchestrator: ChatOrchestrator | null = null;
// chatOrchestrator now resolved per-project via getChatOrchestrator(projectId)
constructor() {
// 从环境变量读取配置
@@ -322,10 +322,17 @@ export class WechatCallbackController {
'🫡 正在查询,请稍候...'
);
if (!this.chatOrchestrator) {
this.chatOrchestrator = await getChatOrchestrator();
const userMapping = await prisma.iitUserMapping.findFirst({
where: { wecomUserId: fromUser },
select: { projectId: true },
orderBy: { updatedAt: 'desc' },
});
if (!userMapping) {
await wechatService.sendTextMessage(fromUser, '您尚未关联任何 IIT 项目,请联系管理员配置。');
return;
}
const aiResponse = await this.chatOrchestrator.handleMessage(fromUser, content);
const orchestrator = await getChatOrchestrator(userMapping.projectId);
const aiResponse = await orchestrator.handleMessage(fromUser, content);
// 主动推送AI回复
await wechatService.sendTextMessage(fromUser, aiResponse);

View File

@@ -507,10 +507,11 @@ export async function registerIitRoutes(fastify: FastifyInstance) {
schema: {
body: {
type: 'object',
required: ['message'],
required: ['message', 'projectId'],
properties: {
message: { type: 'string' },
userId: { type: 'string' },
projectId: { type: 'string' },
},
},
},
@@ -518,9 +519,9 @@ export async function registerIitRoutes(fastify: FastifyInstance) {
async (request: any, reply) => {
const startTime = Date.now();
try {
const { message, userId } = request.body;
const { message, userId, projectId } = request.body;
const uid = userId || request.user?.id || 'web-user';
const orchestrator = await getChatOrchestrator();
const orchestrator = await getChatOrchestrator(projectId);
const rawReply = await orchestrator.handleMessage(uid, message);
const cleanReply = sanitizeLlmReply(rawReply);

View File

@@ -78,6 +78,10 @@ export class ChatOrchestrator {
});
}
getProjectId(): string {
return this.projectId;
}
async handleMessage(userId: string, userMessage: string): Promise<string> {
const startTime = Date.now();
@@ -86,7 +90,7 @@ export class ChatOrchestrator {
}
try {
const history = sessionMemory.getHistory(userId, 2);
const history = sessionMemory.getHistory(userId, 2, this.projectId);
const historyMessages: Message[] = history.map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content,
@@ -180,34 +184,31 @@ export class ChatOrchestrator {
}
private saveConversation(userId: string, userMsg: string, aiMsg: string, startTime: number): void {
sessionMemory.addMessage(userId, 'user', userMsg);
sessionMemory.addMessage(userId, 'assistant', aiMsg);
sessionMemory.addMessage(userId, 'user', userMsg, this.projectId);
sessionMemory.addMessage(userId, 'assistant', aiMsg, this.projectId);
logger.info('[ChatOrchestrator] Conversation saved', {
userId,
projectId: this.projectId,
duration: `${Date.now() - startTime}ms`,
});
}
}
// Resolve the active project ID from DB
async function resolveActiveProjectId(): Promise<string> {
const project = await prisma.iitProject.findFirst({
where: { status: 'active' },
select: { id: true },
});
if (!project) throw new Error('No active IIT project found');
return project.id;
}
// Per-project orchestrator cache
const orchestratorCache = new Map<string, ChatOrchestrator>();
// Singleton factory — lazily resolves active project
let orchestratorInstance: ChatOrchestrator | null = null;
export async function getChatOrchestrator(): Promise<ChatOrchestrator> {
if (!orchestratorInstance) {
const projectId = await resolveActiveProjectId();
orchestratorInstance = new ChatOrchestrator(projectId);
await orchestratorInstance.initialize();
export async function getChatOrchestrator(projectId: string): Promise<ChatOrchestrator> {
if (!projectId) {
throw new Error('projectId is required for ChatOrchestrator');
}
return orchestratorInstance;
let instance = orchestratorCache.get(projectId);
if (!instance) {
instance = new ChatOrchestrator(projectId);
await instance.initialize();
orchestratorCache.set(projectId, instance);
logger.info('[ChatOrchestrator] Created new instance', { projectId });
}
return instance;
}

View File

@@ -84,7 +84,8 @@ async function runTests() {
let orchestrator;
try {
console.log('\n🔧 Initializing ChatOrchestrator...');
orchestrator = await getChatOrchestrator();
const testProjectId = process.env.TEST_PROJECT_ID || 'test0102-pd-study';
orchestrator = await getChatOrchestrator(testProjectId);
console.log('✅ ChatOrchestrator initialized successfully\n');
} catch (error: any) {
console.error('❌ Failed to initialize ChatOrchestrator:', error.message);