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:
@@ -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 } },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
你是一位专业的医学期刊编辑,负责评估稿件的规范性。你将严格按照中华医学超声杂志的稿约标准对稿件进行评估。
|
||||
你是一位专业的医学期刊编辑,负责评估稿件的规范性。你将严格按照中华脑血管病杂志的稿约标准对稿件进行评估。
|
||||
|
||||
【你的职责】
|
||||
1. 仔细阅读稿件的每个部分
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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. 获取模块详细信息
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
// ============================================================
|
||||
|
||||
@@ -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 业务报表路由
|
||||
// ============================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user