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
- 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 the categories listed above
- 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);

View File

@@ -1,10 +1,15 @@
# IIT Manager Agent模块 - 当前状态与开发指南
> **文档版本:** v3.1
> **文档版本:** v3.2
> **创建日期:** 2026-01-01
> **维护者:** IIT Manager开发团队
> **最后更新:** 2026-03-01 **GCP 业务报表 + AI 工作流水增强 + 多项 Bug 修复完成**
> **最后更新:** 2026-03-02 **数据一致性修复 + 项目隔离 + 管理端配置流重设计 + 中文显示名**
> **重大里程碑:**
> - **2026-03-02QC 数据一致性修复!** AI 时间线 + 警告详情 统一从 qc_field_statusSSOT读取与热力图数据一致
> - **2026-03-02字段/事件中文显示名!** LEFT JOIN field_metadata + qc_event_status消除 REDCap 技术标识符
> - **2026-03-02警告详情可查看** 新增 field-issues 分页 API + ReportsPage 严重问题/警告数字可点击弹出详情 Modal
> - **2026-03-02AI 对话项目隔离!** ChatOrchestrator 按 projectId 缓存实例 + SessionMemory 按 userId::projectId 隔离
> - **2026-03-02管理端配置流重设计** 5 个配置 Tab 按依赖关系重排 + AI 自动构建质控规则D1/D3/D5/D6 四维度)
> - **2026-03-01GCP 业务端报表全量完成!** 4 张 GCP 标准报表D1 筛选入选/D2 完整性/D3D4 质疑跟踪/D6 方案偏离)后端 API + 前端组件 + ReportsPage 五 Tab 重构
> - **2026-03-01AI 工作流水时间线增强!** 实际规则数显示33 条而非 1 条)+ 中文事件名 + 可展开问题详情表格 + severity 映射修复
> - **2026-03-01业务端一键全量质控** DashboardPage 新增按钮 + 自动刷新报告缓存 + 事件级通过率修复
@@ -61,7 +66,16 @@ CRA Agent 是一个**替代 CRA 岗位的自主 AI Agent**,而非辅助 CRA
- AI能力DeepSeek/Qwen + 自研 RAGpgvector+ LLM Tool Use
### 当前状态
- **开发阶段****V3.1 质控引擎 + GCP 业务报表 + AI 时间线增强 + Bug 修复 → 待部署验证**
- **开发阶段****V3.2 数据一致性 + 项目隔离 + 管理端重设计 + 中文显示名 → 待部署验证**
- **V3.2 数据一致性 + 项目隔离已完成**2026-03-02
- AI 时间线改为从 qc_field_statusSSOT聚合与风险热力图数据一致
- 新增 field-issues 分页查询 API支持按维度/严重程度/受试者筛选)
- ReportsPage 严重问题/警告数字可点击弹出详情 Modal分页+按维度筛选)
- 字段名/事件名中文显示LEFT JOIN field_metadata + qc_event_status 替代 REDCap 技术标识符)
- AI 对话项目隔离ChatOrchestrator 按 projectId 缓存 + SessionMemory 按 userId::projectId 隔离 + WeChat 回调自动解析 projectId
- 管理端 IIT 配置流重设计5 个 Tab 按依赖关系重排REDCap→变量清单→知识库→质控规则→成员
- AI 自动构建质控规则D3 程序化 + D1/D5/D6 LLM 生成 + 预览批量导入 + 规则追加而非覆盖)
- 批量 QC 后自动调用 DailyQcOrchestrator 派发 eQuery
- **GCP 业务报表 + Bug 修复已完成**2026-03-01
- 4 张 GCP 标准报表后端 APIiitQcCockpitServicegetEligibilityReport/getCompletenessReport/getEqueryReport/getDeviationReport
- 4 个前端报表组件EligibilityTable/CompletenessTable/EqueryLogTable/DeviationLogTable
@@ -107,6 +121,37 @@ CRA Agent 是一个**替代 CRA 岗位的自主 AI Agent**,而非辅助 CRA
-**端到端测试通过**REDCap → Node.js → 企业微信)
-~~AI对话集成完成ChatService + SessionMemory~~ → 已替换为 ChatOrchestrator
#### ✅ 已完成功能V3.2 数据一致性 + 项目隔离 + 管理端重设计 - 2026-03-02
-**QC 数据一致性修复qc_field_status 为 SSOT**
- AI 时间线 getTimeline 改为从 qc_field_status 按受试者分组聚合(替代 qc_logs
- 与风险热力图、质控报告数据来源统一,消除数据不一致问题
- 时间线展示有问题的受试者FAIL/WARNING+ 全部通过的受试者PASS
-**字段/事件中文显示名**
- getTimeline + getFieldIssues SQL 增加 LEFT JOIN field_metadata字段中文标签+ qc_event_status事件中文标签
- 前端优先显示 fieldLabel / eventLabel回退到原始技术标识符
- 消除 `65a64dbbd9_arm_1``check_date` 等不可读标识符
-**警告详情可查看**
- 新增 GET /:projectId/qc-cockpit/field-issues 分页查询 API支持按 severity/dimension/recordId 筛选)
- 返回聚合统计bySeverity + byDimension
- ReportsPage "严重问题"/"警告"卡片可点击,弹出详情 Modal分页+维度下拉筛选)
-**AI 对话项目隔离**
- ChatOrchestrator 从单例改为按 projectId 缓存实例orchestratorCache Map
- SessionMemory 会话键改为 `userId::projectId`,不同项目聊天历史互不干扰
- 后端 /api/v1/iit/chat 路由要求传 projectId
- WechatCallbackController 通过 iitUserMapping 自动解析用户 projectId
-**管理端 IIT 配置流重设计**
- 5 个配置 Tab 按依赖关系重排:① REDCap 配置 → ② 变量清单 → ③ 知识库 → ④ 质控规则 → ⑤ 项目成员
- "同步元数据" 按钮从基础配置移至变量清单 Tab
- 各 Tab 增加前置条件检查和提示(如"请先配置 REDCap 并同步变量"
-**AI 自动构建质控规则**
- D3 准确性:基于 field_metadata 程序化生成范围/枚举/必填检查(无需 LLM
- D1 入选排除 / D5 安全性 / D6 方案偏离LLM 基于变量清单+知识库智能生成
- 预览 Modal 支持多选批量导入 + 规则追加(不覆盖已有规则)
- 规则按 D1-D7 维度分组展示Collapse 折叠面板)
-**批量 QC 后自动派发 eQuery**
- iitBatchController 执行 QcExecutor.executeBatch 后调用 DailyQcOrchestrator.orchestrate
- 返回 equeriesCreated 计数
#### ✅ 已完成功能GCP 业务报表 + AI 时间线 + Bug 修复 - 2026-03-01
-**GCP 标准报表(阶段 A 4 张)**
- D1 筛选入选表getEligibilityReportrecord_summary 全量 + qc_field_status D1 叠加)
@@ -954,8 +999,8 @@ npx ts-node src/modules/iit-manager/test-wechat-push.ts
---
> **提示**本文档反映IIT Manager Agent模块的最新真实状态每个里程碑完成后必须更新
> **最后更新**2026-03-01
> **当前进度**V3.1 QC Engine 完成 | GCP 业务报表 4 张全量完成 | AI Timeline 增强 | 一键全量质控 | 多项 Bug 修复 | Phase 2: LLM 执行摘要待开发
> **最后更新**2026-03-02
> **当前进度**V3.2 数据一致性修复 | 项目隔离 | 管理端配置流重设计 | AI 规则自动生成 | 中文显示名 | 警告详情 Modal | 待部署验证
> **核心文档**
> - [CRA Agent V3.0 开发计划](./04-开发计划/V3.0全新开发计划/V3.0全新开发计划.md) ⭐⭐⭐⭐⭐
> - [统一数字 CRA 质控平台 PRD](./04-开发计划/V3.0全新开发计划/统一数字%20CRA%20质控平台产品需求文档(PRD).md) ⭐⭐⭐⭐⭐

View File

@@ -15,19 +15,35 @@
| # | 变更内容 | 迁移文件 | 优先级 | 备注 |
|---|---------|---------|--------|------|
| — | *暂无* | | | |
| DB-1 | modules 表新增 `AIA_PROTOCOL``RM` 模块注册IIT 名称改为「CRA质控」 | 无(运行 seed 脚本) | 高 | 运行 `node scripts/seed-modules.js`upsert可重复执行 |
| DB-2 | RVW Prompt 更新:期刊名称修正为「中华脑血管病杂志」 | 无(重新运行迁移脚本或后台编辑) | 高 | 运行 `npx tsx scripts/migrate-rvw-prompts.ts` 或在 `/admin/prompts/RVW_EDITORIAL` 手动编辑 |
### 后端变更 (Node.js)
| # | 变更内容 | 涉及文件 | 需要操作 | 备注 |
|---|---------|---------|---------|------|
| — | *暂无* | | | |
| BE-1 | 登录踢人机制:同一手机号只能一人同时在线 | `auth.service.ts`, `auth.middleware.ts`, `jwt.service.ts` | 重新构建镜像 | JWT 增加 tokenVersion登录时递增版本号存入缓存认证中间件校验版本号 |
| BE-2 | 模块权限一致性修复:`/me/modules` API 现在尊重 user_modules 精细化配置 | `module.service.ts` | 重新构建镜像 | 之前 `/me/modules` 只看 tenant_modules现在与登录逻辑一致 |
| BE-3 | getModuleName 补充 RM、AIA_PROTOCOL、IIT 名称修正 | `userService.ts` | 重新构建镜像 | IIT Manager → CRA质控 |
| BE-4 | RVW 稿约 Prompt 源文件修正 | `prompts/review_editorial_system.txt` | 重新构建镜像 | 中华医学超声杂志 → 中华脑血管病杂志 |
| BE-5 | seed 数据:内部租户补充 RM、AIA_PROTOCOL 模块 | `prisma/seed.ts` | 仅影响新环境初始化 | — |
| BE-6 | 用户模块配置校验放宽:不再限制必须在租户订阅范围内 | `userService.ts` | 重新构建镜像 | 校验改为「模块代码必须在 modules 表中存在」,支持给用户单独开通功能模块 |
| BE-7 | 用户独立模块生效user_modules 中的模块即使租户未订阅也纳入权限 | `module.service.ts` | 重新构建镜像 | 如 AIA_PROTOCOL 可单独配给用户 |
### 前端变更
| # | 变更内容 | 涉及文件 | 需要操作 | 备注 |
|---|---------|---------|---------|------|
| — | *暂无* | | | |
| FE-1 | ASL 模块:隐藏数据源/年限/篇数,默认 PubMed | `SetupPanel.tsx` | 重新构建镜像 | 高级筛选面板全部隐藏 |
| FE-2 | ASL 模块:去掉「研究方案生成」「文献管理」,重新编号 1~6 | `ASLLayout.tsx` | 重新构建镜像 | — |
| FE-3 | ASL 模块:默认进入智能文献检索(不再是标题摘要初筛) | `asl/index.tsx` | 重新构建镜像 | 路由 index → research/deep |
| FE-4 | AIA 模块删除「已接入DeepSeek」和搜索框 | `AgentHub.tsx` | 重新构建镜像 | — |
| FE-5 | AIA 模块Protocol Agent 按用户模块权限动态显示 | `AgentHub.tsx`, `constants.ts` | 重新构建镜像 | 通过 `hasModule('AIA_PROTOCOL')` 判断,需 DB-1 配合 |
| FE-6 | AIA 模块:数据评价与预处理 / 智能统计分析链接修正 | `constants.ts` | 重新构建镜像 | `/dc``/research-management` |
| FE-7 | 首页重定向到 `/ai-qa`,不再显示模块卡片首页 | `App.tsx` | 重新构建镜像 | — |
| FE-8 | PKB 模块:创建知识库时隐藏科室选择,默认 General | `DashboardPage.tsx` | 重新构建镜像 | — |
| FE-9 | 被踢出时前端提示「账号已在其他设备登录」 | `axios.ts` | 重新构建镜像 | 配合 BE-1 |
| FE-10 | 运营端:用户模块权限弹窗显示所有模块(含 RM、AIA_PROTOCOL、CRA质控 | `ModulePermissionModal.tsx` | 重新构建镜像 | 未订阅模块标注"(未订阅)",不阻止配置 |
### Python 微服务变更
@@ -53,6 +69,17 @@
|---|---------|------|------|
| — | *暂无* | | |
### 部署顺序建议
1. **后端先部署**:构建并推送新镜像(包含 BE-1 ~ BE-5
2. **运行数据库脚本**
- `node scripts/seed-modules.js`DB-1注册 AIA_PROTOCOL、RM 模块)
- `npx tsx scripts/migrate-rvw-prompts.ts`DB-2更新 RVW 稿约 Prompt
3. **前端部署**:构建并推送新镜像(包含 FE-1 ~ FE-9
4. **后台配置**
- 为内部测试人员所在租户开通 `AIA_PROTOCOL` 模块
- 确认 SSA/ST/IIT/RM 模块权限按需分配给对应租户和用户
---
## 记录模板

View File

@@ -8,7 +8,6 @@ import { RouteGuard } from './framework/router'
import MainLayout from './framework/layout/MainLayout'
import AdminLayout from './framework/layout/AdminLayout'
import OrgLayout from './framework/layout/OrgLayout'
import HomePage from './pages/HomePage'
import LoginPage from './pages/LoginPage'
import AdminDashboard from './pages/admin/AdminDashboard'
import OrgDashboard from './pages/org/OrgDashboard'
@@ -80,8 +79,8 @@ function App() {
{/* 业务应用端 /app/* */}
<Route path="/" element={<MainLayout />}>
{/* 首页 */}
<Route index element={<HomePage />} />
{/* 首页重定向到 AI 问答 */}
<Route index element={<Navigate to="/ai-qa" replace />} />
{/* 动态加载模块路由 - 基于模块权限系统 ⭐ 2026-01-16 */}
{MODULES.filter(m => !m.isExternal).map(module => (

View File

@@ -62,9 +62,16 @@ apiClient.interceptors.response.use(
const hasRefreshToken = !!getRefreshToken();
const alreadyRetried = originalRequest._retry;
if (!is401 || !hasRefreshToken || alreadyRetried) {
if (is401 && !hasRefreshToken) {
// 检测是否被踢出(其他设备登录)
const responseMsg = (error.response?.data as any)?.message || '';
const isKicked = is401 && responseMsg.includes('其他设备');
if (!is401 || !hasRefreshToken || alreadyRetried || isKicked) {
if (is401) {
clearTokens();
if (isKicked) {
alert('您的账号已在其他设备登录,当前会话已失效,请重新登录');
}
window.location.href = '/login';
}
return Promise.reject(error);

View File

@@ -195,6 +195,24 @@ export async function testRule(
return response.data.data;
}
/** AI 规则建议(支持按维度生成) */
export async function suggestRules(
projectId: string,
dimension?: string
): Promise<import('../types/iitProject').RuleSuggestion[]> {
const params = dimension ? { dimension } : {};
const response = await apiClient.post(`${BASE_URL}/${projectId}/rules/suggest`, {}, { params });
return response.data.data;
}
/** D3 规则自动生成(数据驱动,无需 LLM */
export async function generateD3Rules(
projectId: string
): Promise<import('../types/iitProject').RuleSuggestion[]> {
const response = await apiClient.post(`${BASE_URL}/${projectId}/rules/generate-d3`);
return response.data.data;
}
// ==================== 用户映射 ====================
/** 获取角色选项 */

View File

@@ -73,9 +73,6 @@ const ModulePermissionModal: React.FC<ModulePermissionModalProps> = ({
}
};
// 可用模块(租户已订阅的)
const subscribedModules = moduleOptions.filter((m) => m.isSubscribed);
return (
<Modal
title={`配置模块权限 - ${membership.tenantName}`}
@@ -88,28 +85,35 @@ const ModulePermissionModal: React.FC<ModulePermissionModalProps> = ({
<Spin spinning={loading}>
<Alert
message="模块权限说明"
description="选择用户在该租户内可以访问的模块。取消所有选择将默认继承租户的全部模块权限。"
description="选择用户在该租户内可以访问的模块。取消所有选择将默认继承租户的全部模块权限。灰色模块表示租户尚未订阅。"
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
{subscribedModules.length > 0 ? (
{moduleOptions.length > 0 ? (
<Checkbox.Group
value={selectedModules}
onChange={(values) => setSelectedModules(values as string[])}
style={{ width: '100%' }}
>
<Row gutter={[16, 16]}>
{subscribedModules.map((module) => (
{moduleOptions.map((module) => (
<Col span={12} key={module.code}>
<Checkbox value={module.code}>{module.name}</Checkbox>
<Checkbox value={module.code}>
{module.name}
{!module.isSubscribed && (
<Text type="secondary" style={{ fontSize: 12, marginLeft: 4 }}>
()
</Text>
)}
</Checkbox>
</Col>
))}
</Row>
</Checkbox.Group>
) : (
<Text type="secondary"></Text>
<Text type="secondary"></Text>
)}
</Spin>
</Modal>

View File

@@ -27,6 +27,7 @@ import {
Empty,
Tooltip,
Badge,
Collapse,
} from 'antd';
import {
ArrowLeftOutlined,
@@ -42,6 +43,8 @@ import {
BarChartOutlined,
DashboardOutlined,
ClockCircleOutlined,
BulbOutlined,
RobotOutlined,
} from '@ant-design/icons';
import * as iitProjectApi from '../api/iitProjectApi';
import type {
@@ -49,6 +52,7 @@ import type {
UpdateProjectRequest,
QCRule,
CreateRuleRequest,
RuleSuggestion,
IitUserMapping,
CreateUserMappingRequest,
RoleOption,
@@ -65,13 +69,27 @@ const SEVERITY_MAP = {
info: { color: 'processing', text: '信息' },
};
const CATEGORY_MAP = {
const CATEGORY_MAP: Record<string, { color: string; text: string }> = {
D1: { color: '#52c41a', text: 'D1 入选/排除' },
D2: { color: '#13c2c2', text: 'D2 完整性' },
D3: { color: '#1890ff', text: 'D3 准确性' },
D4: { color: '#faad14', text: 'D4 质疑管理' },
D5: { color: '#ff4d4f', text: 'D5 安全性' },
D6: { color: '#722ed1', text: 'D6 方案偏离' },
D7: { color: '#eb2f96', text: 'D7 药物管理' },
inclusion: { color: '#52c41a', text: '纳入标准' },
exclusion: { color: '#ff4d4f', text: '排除标准' },
lab_values: { color: '#1890ff', text: '变量范围' },
logic_check: { color: '#722ed1', text: '逻辑检查' },
};
const AI_BUILD_DIMENSIONS = [
{ key: 'D3', label: 'D3 准确性规则', description: '基于变量定义自动生成范围/枚举检查', icon: <BarChartOutlined />, needsKb: false, isD3: true },
{ key: 'D1', label: 'D1 入选/排除规则', description: 'AI 基于知识库生成纳入排除标准检查', icon: <CheckCircleOutlined />, needsKb: true, isD3: false },
{ key: 'D5', label: 'D5 安全性规则', description: 'AI 生成 AE/SAE 报告时限和完整性检查', icon: <ExclamationCircleOutlined />, needsKb: true, isD3: false },
{ key: 'D6', label: 'D6 方案偏离规则', description: 'AI 生成访视窗口期和用药合规性检查', icon: <BookOutlined />, needsKb: true, isD3: false },
];
// ==================== 主组件 ====================
const IitProjectDetailPage: React.FC = () => {
@@ -143,31 +161,34 @@ const IitProjectDetailPage: React.FC = () => {
return <Empty description="项目不存在" />;
}
const hasFields = !!project.lastSyncAt;
const hasKb = !!(project.knowledgeBaseId || project.knowledgeBase?.id);
const tabItems = [
{
key: 'basic',
label: 'REDCap 配置',
label: 'REDCap 配置',
children: <BasicConfigTab project={project} onUpdate={loadProject} />,
},
{
key: 'rules',
label: '质控规则',
children: <QCRulesTab projectId={project.id} />,
},
{
key: 'members',
label: '项目成员',
children: <UserMappingTab projectId={project.id} />,
key: 'fields',
label: '② 变量清单',
children: <FieldMetadataTab projectId={project.id} project={project} onUpdate={loadProject} />,
},
{
key: 'kb',
label: '知识库',
children: <KnowledgeBaseTab project={project} onUpdate={loadProject} />,
label: '知识库',
children: <KnowledgeBaseTab project={project} onUpdate={loadProject} hasFields={hasFields} />,
},
{
key: 'fields',
label: '变量清单',
children: <FieldMetadataTab projectId={project.id} />,
key: 'rules',
label: `④ 质控规则`,
children: <QCRulesTab projectId={project.id} project={project} hasFields={hasFields} hasKb={hasKb} />,
},
{
key: 'members',
label: '⑤ 项目成员',
children: <UserMappingTab projectId={project.id} />,
},
];
@@ -238,7 +259,6 @@ const BasicConfigTab: React.FC<BasicConfigTabProps> = ({ project, onUpdate }) =>
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [syncing, setSyncing] = useState(false);
useEffect(() => {
form.setFieldsValue({
@@ -281,18 +301,6 @@ const BasicConfigTab: React.FC<BasicConfigTabProps> = ({ project, onUpdate }) =>
}
};
const handleSyncMetadata = async () => {
setSyncing(true);
try {
const result = await iitProjectApi.syncMetadata(project.id);
message.success(`同步成功!共 ${result.fieldCount} 个字段`);
} catch (error) {
message.error('同步失败');
} finally {
setSyncing(false);
}
};
return (
<div>
<Form form={form} layout="vertical" onFinish={handleSave} style={{ maxWidth: 600 }}>
@@ -394,9 +402,6 @@ const BasicConfigTab: React.FC<BasicConfigTabProps> = ({ project, onUpdate }) =>
<Button icon={<CheckCircleOutlined />} onClick={handleTestConnection} loading={testing}>
</Button>
<Button icon={<SyncOutlined />} onClick={handleSyncMetadata} loading={syncing}>
</Button>
</Space>
</Form.Item>
</Form>
@@ -417,19 +422,28 @@ const BasicConfigTab: React.FC<BasicConfigTabProps> = ({ project, onUpdate }) =>
);
};
// ==================== Tab 2: 质控规则 ====================
// ==================== Tab 4: 质控规则 ====================
interface QCRulesTabProps {
projectId: string;
project: IitProject;
hasFields: boolean;
hasKb: boolean;
}
const QCRulesTab: React.FC<QCRulesTabProps> = ({ projectId }) => {
const QCRulesTab: React.FC<QCRulesTabProps> = ({ projectId, project: _project, hasFields, hasKb }) => {
const [rules, setRules] = useState<QCRule[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<QCRule | null>(null);
const [form] = Form.useForm();
const [aiLoading, setAiLoading] = useState<string | null>(null);
const [suggestions, setSuggestions] = useState<RuleSuggestion[]>([]);
const [selectedSuggestionKeys, setSelectedSuggestionKeys] = useState<React.Key[]>([]);
const [previewOpen, setPreviewOpen] = useState(false);
const [importing, setImporting] = useState(false);
const loadRules = useCallback(async () => {
setLoading(true);
try {
@@ -483,7 +497,7 @@ const QCRulesTab: React.FC<QCRulesTabProps> = ({ projectId }) => {
logic: JSON.parse(logicValue),
message: values.message as string,
severity: values.severity as 'error' | 'warning' | 'info',
category: values.category as 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check',
category: values.category as CreateRuleRequest['category'],
};
if (editingRule) {
@@ -501,22 +515,73 @@ const QCRulesTab: React.FC<QCRulesTabProps> = ({ projectId }) => {
}
};
const columns = [
const handleAiBuild = async (dim: typeof AI_BUILD_DIMENSIONS[number]) => {
if (!hasFields) {
message.warning('请先在「② 变量清单」中同步元数据');
return;
}
if (dim.needsKb && !hasKb) {
message.warning('此维度需要知识库支持,请先在「③ 知识库」中创建并上传文档');
return;
}
setAiLoading(dim.key);
try {
let result: RuleSuggestion[];
if (dim.isD3) {
result = await iitProjectApi.generateD3Rules(projectId);
} else {
result = await iitProjectApi.suggestRules(projectId, dim.key);
}
if (result.length === 0) {
message.info('未生成规则,请检查变量清单或知识库内容');
return;
}
setSuggestions(result);
setSelectedSuggestionKeys(result.map((_, i) => i));
setPreviewOpen(true);
} catch (err: any) {
message.error(err?.response?.data?.error || err?.message || 'AI 生成失败');
} finally {
setAiLoading(null);
}
};
const handleImportSelected = async () => {
const selected = selectedSuggestionKeys.map((k) => suggestions[k as number]).filter(Boolean);
if (selected.length === 0) {
message.warning('请至少选择一条规则');
return;
}
setImporting(true);
try {
const rulesToImport: CreateRuleRequest[] = selected.map((s) => ({
name: s.name,
field: s.field,
logic: s.logic,
message: s.message,
severity: s.severity,
category: s.category as CreateRuleRequest['category'],
}));
const result = await iitProjectApi.importRules(projectId, rulesToImport);
message.success(`成功导入 ${result.count} 条规则`);
setPreviewOpen(false);
setSuggestions([]);
setSelectedSuggestionKeys([]);
loadRules();
} catch (err: any) {
message.error(err?.response?.data?.error || '导入失败');
} finally {
setImporting(false);
}
};
const ruleColumns = [
{
title: '规则名称',
dataIndex: 'name',
key: 'name',
width: 200,
},
{
title: '分类',
dataIndex: 'category',
key: 'category',
width: 100,
render: (category: keyof typeof CATEGORY_MAP) => {
const cat = CATEGORY_MAP[category];
return <Tag color={cat?.color}>{cat?.text || category}</Tag>;
},
width: 220,
},
{
title: '严重程度',
@@ -560,29 +625,149 @@ const QCRulesTab: React.FC<QCRulesTabProps> = ({ projectId }) => {
},
];
const DIMENSION_ORDER = ['D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7'];
const groupedRules = React.useMemo(() => {
const groups: Record<string, QCRule[]> = {};
for (const rule of rules) {
const cat = rule.category || 'other';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(rule);
}
const sorted: Record<string, QCRule[]> = {};
for (const dim of DIMENSION_ORDER) {
if (groups[dim]) { sorted[dim] = groups[dim]; delete groups[dim]; }
}
for (const [key, val] of Object.entries(groups)) {
sorted[key] = val;
}
return sorted;
}, [rules]);
const previewColumns = [
{ title: '规则名称', dataIndex: 'name', key: 'name', width: 200 },
{
title: '分类',
dataIndex: 'category',
key: 'category',
width: 120,
render: (c: string) => {
const cat = CATEGORY_MAP[c];
return <Tag color={cat?.color}>{cat?.text || c}</Tag>;
},
},
{
title: '严重程度',
dataIndex: 'severity',
key: 'severity',
width: 100,
render: (s: string) => {
const sev = SEVERITY_MAP[s as keyof typeof SEVERITY_MAP];
return <Tag color={sev?.color}>{sev?.text || s}</Tag>;
},
},
{
title: '字段',
dataIndex: 'field',
key: 'field',
width: 150,
render: (f: string | string[]) => <Text code>{Array.isArray(f) ? f.join(', ') : f}</Text>,
},
{ title: '提示信息', dataIndex: 'message', key: 'message', ellipsis: true },
];
if (!hasFields) {
return (
<Alert
message="请先完成变量清单同步"
description="质控规则依赖变量清单数据。请先在「② 变量清单」Tab 中从 REDCap 同步元数据。"
type="warning"
showIcon
icon={<ExclamationCircleOutlined />}
/>
);
}
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<Alert
message="质控规则使用 JSON Logic 格式定义,用于数据质量检查"
type="info"
showIcon
style={{ flex: 1, marginRight: 16 }}
/>
{/* AI 自动构建规则区域 */}
<Card
size="small"
title={<Space><RobotOutlined /> AI </Space>}
style={{ marginBottom: 16, border: '1px solid #d9d9d9', background: '#fafafa' }}
>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{AI_BUILD_DIMENSIONS.map((dim) => {
const disabled = dim.needsKb && !hasKb;
return (
<Tooltip
key={dim.key}
title={disabled ? '需要先创建知识库并上传文档' : dim.description}
>
<Button
icon={dim.icon}
loading={aiLoading === dim.key}
disabled={disabled || !!aiLoading}
onClick={() => handleAiBuild(dim)}
style={{ height: 'auto', padding: '8px 16px' }}
>
<div style={{ textAlign: 'left' }}>
<div style={{ fontWeight: 500 }}>{dim.label}</div>
<div style={{ fontSize: 11, color: '#888', marginTop: 2 }}>
{dim.isD3 ? '数据驱动(无需 LLM' : 'AI 生成(需要知识库)'}
</div>
</div>
</Button>
</Tooltip>
);
})}
</div>
{!hasKb && (
<Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
D3 D1/D5/D6
</Text>
)}
</Card>
{/* 规则列表(按维度分组) */}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text strong> ({rules.length})</Text>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
</Button>
</div>
<Table
columns={columns}
dataSource={rules}
rowKey="id"
loading={loading}
pagination={false}
size="small"
/>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>
) : rules.length === 0 ? (
<Empty description="暂无规则,请使用上方 AI 构建或手动添加" />
) : (
<Collapse
defaultActiveKey={Object.keys(groupedRules)}
items={Object.entries(groupedRules).map(([cat, catRules]) => {
const catMeta = CATEGORY_MAP[cat];
return {
key: cat,
label: (
<Space>
<Tag color={catMeta?.color}>{catMeta?.text || cat}</Tag>
<Text type="secondary">{catRules.length} </Text>
</Space>
),
children: (
<Table
columns={ruleColumns}
dataSource={catRules}
rowKey="id"
pagination={false}
size="small"
/>
),
};
})}
/>
)}
{/* 手动编辑规则 Modal */}
<Modal
title={editingRule ? '编辑规则' : '添加规则'}
open={modalOpen}
@@ -597,10 +782,15 @@ const QCRulesTab: React.FC<QCRulesTabProps> = ({ projectId }) => {
<Form.Item name="category" label="分类" rules={[{ required: true }]}>
<Select
options={Object.entries(CATEGORY_MAP).map(([value, { text }]) => ({
value,
label: text,
}))}
options={[
{ value: 'D1', label: 'D1 入选/排除' },
{ value: 'D2', label: 'D2 完整性' },
{ value: 'D3', label: 'D3 准确性' },
{ value: 'D4', label: 'D4 质疑管理' },
{ value: 'D5', label: 'D5 安全性' },
{ value: 'D6', label: 'D6 方案偏离' },
{ value: 'D7', label: 'D7 药物管理' },
]}
/>
</Form.Item>
@@ -660,6 +850,55 @@ const QCRulesTab: React.FC<QCRulesTabProps> = ({ projectId }) => {
</Form.Item>
</Form>
</Modal>
{/* AI 规则预览 + 批量导入 Modal */}
<Modal
title={
<Space>
<BulbOutlined style={{ color: '#faad14' }} />
AI
</Space>
}
open={previewOpen}
onCancel={() => { setPreviewOpen(false); setSuggestions([]); setSelectedSuggestionKeys([]); }}
width={900}
footer={
<Space>
<Text type="secondary"> {selectedSuggestionKeys.length} / {suggestions.length} </Text>
<Button onClick={() => { setPreviewOpen(false); setSuggestions([]); setSelectedSuggestionKeys([]); }}>
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
loading={importing}
disabled={selectedSuggestionKeys.length === 0}
onClick={handleImportSelected}
>
</Button>
</Space>
}
>
<Alert
message="以下规则由 AI 自动生成,请检查后勾选需要导入的规则。导入后可随时编辑或删除。"
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Table
columns={previewColumns}
dataSource={suggestions.map((s, i) => ({ ...s, _key: i }))}
rowKey="_key"
size="small"
pagination={false}
scroll={{ y: 400 }}
rowSelection={{
selectedRowKeys: selectedSuggestionKeys,
onChange: (keys) => setSelectedSuggestionKeys(keys),
}}
/>
</Modal>
</div>
);
};
@@ -927,9 +1166,10 @@ const UserMappingTab: React.FC<UserMappingTabProps> = ({ projectId }) => {
interface KnowledgeBaseTabProps {
project: IitProject;
onUpdate: () => void;
hasFields: boolean;
}
const KnowledgeBaseTab: React.FC<KnowledgeBaseTabProps> = ({ project, onUpdate }) => {
const KnowledgeBaseTab: React.FC<KnowledgeBaseTabProps> = ({ project, onUpdate, hasFields }) => {
const [documents, setDocuments] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
@@ -1018,6 +1258,18 @@ const KnowledgeBaseTab: React.FC<KnowledgeBaseTabProps> = ({ project, onUpdate }
}
};
if (!hasFields) {
return (
<Alert
message="请先完成变量清单同步"
description="建议在「② 变量清单」Tab 中先从 REDCap 同步变量元数据,然后再创建知识库。知识库中的研究方案等文档将与变量数据配合,帮助 AI 生成更精准的质控规则。"
type="warning"
showIcon
icon={<ExclamationCircleOutlined />}
/>
);
}
if (!kbId) {
return (
<div>
@@ -1150,20 +1402,25 @@ const KnowledgeBaseTab: React.FC<KnowledgeBaseTabProps> = ({ project, onUpdate }
);
};
// ==================== Tab 5: 变量清单 ====================
// ==================== Tab 2: 变量清单 ====================
interface FieldMetadataTabProps {
projectId: string;
project: IitProject;
onUpdate: () => void;
}
const FieldMetadataTab: React.FC<FieldMetadataTabProps> = ({ projectId }) => {
const FieldMetadataTab: React.FC<FieldMetadataTabProps> = ({ projectId, project, onUpdate }) => {
const [fields, setFields] = useState<any[]>([]);
const [forms, setForms] = useState<string[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [formFilter, setFormFilter] = useState<string | undefined>(undefined);
const [search, setSearch] = useState('');
const hasRedcapConfig = !!(project.redcapUrl && project.redcapApiToken);
const fetchFields = useCallback(async () => {
setLoading(true);
try {
@@ -1184,6 +1441,32 @@ const FieldMetadataTab: React.FC<FieldMetadataTabProps> = ({ projectId }) => {
fetchFields();
}, [fetchFields]);
const handleSyncMetadata = async () => {
setSyncing(true);
try {
const result = await iitProjectApi.syncMetadata(projectId);
message.success(`同步成功!共 ${result.fieldCount} 个字段`);
fetchFields();
onUpdate();
} catch (error) {
message.error('同步元数据失败,请检查 REDCap 配置');
} finally {
setSyncing(false);
}
};
if (!hasRedcapConfig) {
return (
<Alert
message="请先完成 REDCap 配置"
description="变量清单需要从 REDCap 同步获取。请先在「① REDCap 配置」Tab 中填写 REDCap URL 和 API Token 并保存。"
type="warning"
showIcon
icon={<ExclamationCircleOutlined />}
/>
);
}
const columns = [
{
title: '变量名',
@@ -1229,32 +1512,61 @@ const FieldMetadataTab: React.FC<FieldMetadataTabProps> = ({ projectId }) => {
return (
<div>
<Space style={{ marginBottom: 16 }}>
<Select
allowClear
placeholder="按表单筛选"
style={{ width: 200 }}
value={formFilter}
onChange={setFormFilter}
options={forms.map(f => ({ value: f, label: f }))}
{total === 0 && !loading ? (
<Alert
message="尚未同步变量清单"
description="点击下方按钮从 REDCap 同步所有数据变量定义。这是后续配置知识库和质控规则的基础。"
type="info"
showIcon
icon={<SyncOutlined />}
style={{ marginBottom: 16 }}
action={
<Button
type="primary"
size="large"
icon={<SyncOutlined spin={syncing} />}
onClick={handleSyncMetadata}
loading={syncing}
>
REDCap
</Button>
}
/>
<Input.Search
placeholder="搜索变量名或标签"
allowClear
style={{ width: 250 }}
onSearch={setSearch}
) : (
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<Select
allowClear
placeholder="按表单筛选"
style={{ width: 200 }}
value={formFilter}
onChange={setFormFilter}
options={forms.map(f => ({ value: f, label: f }))}
/>
<Input.Search
placeholder="搜索变量名或标签"
allowClear
style={{ width: 250 }}
onSearch={setSearch}
/>
<Text type="secondary"> {total} </Text>
</Space>
<Button icon={<SyncOutlined />} onClick={handleSyncMetadata} loading={syncing}>
</Button>
</div>
)}
{total > 0 && (
<Table
columns={columns}
dataSource={fields}
rowKey="id"
loading={loading}
size="small"
pagination={{ pageSize: 50, showSizeChanger: true, showTotal: t => `${t}` }}
scroll={{ y: 500 }}
/>
<Text type="secondary"> {total} </Text>
</Space>
<Table
columns={columns}
dataSource={fields}
rowKey="id"
loading={loading}
size="small"
pagination={{ pageSize: 50, showSizeChanger: true, showTotal: t => `${t}` }}
scroll={{ y: 500 }}
/>
)}
</div>
);
};

View File

@@ -76,6 +76,12 @@ export interface TestConnectionResult {
// ==================== 质控规则相关 ====================
export type DimensionCode = 'D1' | 'D2' | 'D3' | 'D4' | 'D5' | 'D6' | 'D7';
export type RuleCategory =
| 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check'
| DimensionCode;
export interface QCRule {
id: string;
name: string;
@@ -83,7 +89,7 @@ export interface QCRule {
logic: Record<string, unknown>;
message: string;
severity: 'error' | 'warning' | 'info';
category: 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check';
category: RuleCategory;
metadata?: Record<string, unknown>;
}
@@ -93,10 +99,20 @@ export interface CreateRuleRequest {
logic: Record<string, unknown>;
message: string;
severity: 'error' | 'warning' | 'info';
category: 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check';
category: RuleCategory;
metadata?: Record<string, unknown>;
}
export interface RuleSuggestion {
name: string;
field: string | string[];
logic: Record<string, unknown>;
message: string;
severity: 'error' | 'warning' | 'info';
category: string;
applicableEvents?: string[];
}
export interface RuleStats {
total: number;
byCategory: Record<string, number>;

View File

@@ -8,11 +8,12 @@
* - 5个阶段12个智能体卡片
*/
import React, { useState, useMemo } from 'react';
import { BrainCircuit, Search } from 'lucide-react';
import React, { useMemo } from 'react';
import { BrainCircuit } from 'lucide-react';
import { AgentCard } from './AgentCard';
import { AGENTS, PHASES } from '../constants';
import type { AgentConfig } from '../types';
import { useAuth } from '@/framework/auth';
import '../styles/agent-hub.css';
interface AgentHubProps {
@@ -20,40 +21,32 @@ interface AgentHubProps {
}
export const AgentHub: React.FC<AgentHubProps> = ({ onAgentSelect }) => {
const [searchValue, setSearchValue] = useState('');
const { hasModule } = useAuth();
// Protocol Agent 按用户模块权限动态显示(管理后台配置 AIA_PROTOCOL
const showProtocol = hasModule('AIA_PROTOCOL');
const visiblePhases = useMemo(() => {
return showProtocol ? PHASES : PHASES.filter(p => !p.isProtocolAgent);
}, [showProtocol]);
// 按阶段分组智能体
const agentsByPhase = useMemo(() => {
const grouped: Record<number, AgentConfig[]> = {};
AGENTS.forEach(agent => {
const visibleAgents = showProtocol ? AGENTS : AGENTS.filter(a => !a.isProtocolAgent);
visibleAgents.forEach(agent => {
if (!grouped[agent.phase]) {
grouped[agent.phase] = [];
}
grouped[agent.phase].push(agent);
});
return grouped;
}, []);
// 搜索提交
const handleSearch = () => {
if (searchValue.trim()) {
// 默认进入第一个智能体并携带搜索内容
const firstAgent = AGENTS[0];
onAgentSelect({ ...firstAgent, initialQuery: searchValue } as any);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
}
};
}, [showProtocol]);
return (
<div className="agent-hub">
{/* 主体内容 */}
<main className="hub-main">
{/* 头部搜索区 */}
{/* 头部标题区 */}
<div className="hub-header">
<div className="header-title">
<div className="title-icon">
@@ -61,29 +54,14 @@ export const AgentHub: React.FC<AgentHubProps> = ({ onAgentSelect }) => {
</div>
<h1 className="title-text">
<span className="title-badge">DeepSeek</span>
</h1>
</div>
<div className="search-box">
<input
type="text"
placeholder="输入研究想法例如SGLT2抑制剂对心衰患者预后的影响..."
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onKeyDown={handleKeyDown}
className="search-input"
/>
<button className="search-btn" onClick={handleSearch}>
<Search size={20} />
</button>
</div>
</div>
{/* 流水线模块 */}
<div className="pipeline-container">
{PHASES.map((phase, phaseIndex) => {
const isLast = phaseIndex === PHASES.length - 1;
{visiblePhases.map((phase, phaseIndex) => {
const isLast = phaseIndex === visiblePhases.length - 1;
const agents = agentsByPhase[phase.phase] || [];
// Protocol Agent 阶段特殊处理phase 0单独显示1个卡片

View File

@@ -127,7 +127,7 @@ export const AGENTS: AgentConfig[] = [
phase: 4,
order: 9,
isTool: true,
toolUrl: '/dc',
toolUrl: '/research-management',
},
{
id: 'TOOL_10',
@@ -138,7 +138,7 @@ export const AGENTS: AgentConfig[] = [
phase: 4,
order: 10,
isTool: true,
toolUrl: '/dc/analysis',
toolUrl: '/research-management',
},
// Phase 5: 写作助手 (2个)

View File

@@ -8,9 +8,7 @@
import { Layout, Menu } from 'antd';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import {
FileTextOutlined,
SearchOutlined,
FolderOpenOutlined,
FilterOutlined,
FileSearchOutlined,
DatabaseOutlined,
@@ -32,29 +30,15 @@ const ASLLayout = () => {
// 菜单项配置
const menuItems: MenuItem[] = [
{
key: 'research-plan',
icon: <FileTextOutlined />,
label: '1. 研究方案生成',
disabled: true,
title: '敬请期待'
},
{
key: '/literature/research/deep',
icon: <SearchOutlined />,
label: '2. 智能文献检索',
},
{
key: 'literature-management',
icon: <FolderOpenOutlined />,
label: '3. 文献管理',
disabled: true,
title: '敬请期待'
label: '1. 智能文献检索',
},
{
key: 'title-screening',
icon: <FilterOutlined />,
label: '4. 标题摘要初筛',
label: '2. 标题摘要初筛',
children: [
{
key: '/literature/screening/title/settings',
@@ -76,7 +60,7 @@ const ASLLayout = () => {
{
key: 'fulltext-screening',
icon: <FileSearchOutlined />,
label: '5. 全文复筛',
label: '3. 全文复筛',
children: [
{
key: '/literature/screening/fulltext/settings',
@@ -98,7 +82,7 @@ const ASLLayout = () => {
{
key: 'extraction',
icon: <DatabaseOutlined />,
label: '6. 全文智能提取',
label: '4. 全文智能提取',
children: [
{
key: '/literature/extraction/setup',
@@ -115,12 +99,12 @@ const ASLLayout = () => {
{
key: '/literature/charting',
icon: <ApartmentOutlined />,
label: '7. SR 图表生成器',
label: '5. SR 图表生成器',
},
{
key: '/literature/meta-analysis',
icon: <BarChartOutlined />,
label: '8. Meta 分析引擎',
label: '6. Meta 分析引擎',
},
];

View File

@@ -6,16 +6,13 @@
*/
import { useState, useEffect } from 'react';
import { Card, Checkbox, Button, Input, Select, Spin, Divider, Typography, Tag } from 'antd';
import { Card, Button, Input, Typography, Tag } from 'antd';
import {
ArrowLeftOutlined,
ThunderboltOutlined,
GlobalOutlined,
EditOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import { aslApi } from '../../api';
import type { DataSourceConfig } from '../../types/deepResearch';
const { Text } = Typography;
@@ -43,24 +40,11 @@ const SetupPanel: React.FC<SetupPanelProps> = ({
initialQuery, onSubmit, onBack, loading, collapsed, onExpand,
}) => {
const [query, setQuery] = useState(initialQuery);
const [dataSources, setDataSources] = useState<DataSourceConfig[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [yearRange, setYearRange] = useState<string>('近5年');
const [targetCount, setTargetCount] = useState<string>('全面检索');
const [loadingSources, setLoadingSources] = useState(true);
const [loadingTextIdx, setLoadingTextIdx] = useState(0);
useEffect(() => {
aslApi.getDeepResearchDataSources().then(res => {
const sources = res.data || [];
setDataSources(sources);
setSelectedIds(sources.filter((s: DataSourceConfig) => s.defaultChecked).map((s: DataSourceConfig) => s.id));
}).catch(() => {
setDataSources([]);
}).finally(() => {
setLoadingSources(false);
});
}, []);
// 默认使用 PubMed + 近5年 + 全面检索(数据源/年限/篇数 UI 暂时隐藏)
const yearRange = '近5年';
const targetCount = '全面检索';
useEffect(() => {
if (!loading) { setLoadingTextIdx(0); return; }
@@ -70,21 +54,10 @@ const SetupPanel: React.FC<SetupPanelProps> = ({
return () => clearInterval(timer);
}, [loading]);
const handleToggle = (id: string) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
);
};
const handleSubmit = () => {
const domains = dataSources
.filter(s => selectedIds.includes(s.id))
.map(s => s.domainScope);
onSubmit(query, domains, { yearRange, targetCount });
onSubmit(query, ['PubMed'], { yearRange, targetCount });
};
const selectedNames = dataSources.filter(s => selectedIds.includes(s.id)).map(s => s.label);
if (collapsed) {
return (
<Card size="small" className="!bg-white">
@@ -94,9 +67,7 @@ const SetupPanel: React.FC<SetupPanelProps> = ({
<div className="min-w-0">
<Text strong className="block truncate">{query}</Text>
<div className="flex items-center gap-2 mt-1 flex-wrap">
{selectedNames.map(name => (
<Tag key={name} className="!text-xs !m-0">{name}</Tag>
))}
<Tag className="!text-xs !m-0">PubMed</Tag>
<Text type="secondary" className="text-xs">{yearRange} · {targetCount}</Text>
</div>
</div>
@@ -111,9 +82,6 @@ const SetupPanel: React.FC<SetupPanelProps> = ({
);
}
const englishSources = dataSources.filter(s => s.category === 'english');
const chineseSources = dataSources.filter(s => s.category === 'chinese');
return (
<div>
<div className="flex items-center gap-3 mb-4">
@@ -133,77 +101,7 @@ const SetupPanel: React.FC<SetupPanelProps> = ({
/>
</Card>
<Card className="mb-4" size="small" title={<><GlobalOutlined className="mr-2" /></>}>
{loadingSources ? (
<Spin size="small" />
) : (
<>
<Text type="secondary" className="block mb-3 text-xs"></Text>
<div className="flex flex-col gap-2 mb-4">
{englishSources.map(ds => (
<Checkbox
key={ds.id}
checked={selectedIds.includes(ds.id)}
onChange={() => handleToggle(ds.id)}
>
<span className="font-medium">{ds.label}</span>
<span className="text-gray-400 text-xs ml-2">{ds.domainScope}</span>
</Checkbox>
))}
</div>
<Divider className="!my-3" />
<Text type="secondary" className="block mb-3 text-xs"></Text>
<div className="flex flex-col gap-2">
{chineseSources.map(ds => (
<Checkbox
key={ds.id}
checked={selectedIds.includes(ds.id)}
onChange={() => handleToggle(ds.id)}
>
<span className="font-medium">{ds.label}</span>
<span className="text-gray-400 text-xs ml-2">{ds.domainScope}</span>
</Checkbox>
))}
</div>
</>
)}
</Card>
<Card className="mb-6" size="small" title="高级筛选">
<div className="flex gap-4">
<div className="flex-1">
<Text type="secondary" className="block mb-1 text-xs"></Text>
<Select
value={yearRange}
onChange={setYearRange}
className="w-full"
options={[
{ value: '不限', label: '不限' },
{ value: '近1年', label: '近1年' },
{ value: '近3年', label: '近3年' },
{ value: '近5年', label: '近5年' },
{ value: '近10年', label: '近10年' },
]}
/>
</div>
<div className="flex-1">
<Text type="secondary" className="block mb-1 text-xs"></Text>
<Select
value={targetCount}
onChange={setTargetCount}
className="w-full"
options={[
{ value: '全面检索', label: '全面检索' },
{ value: '约20篇', label: '约20篇' },
{ value: '约50篇', label: '约50篇' },
{ value: '约100篇', label: '约100篇' },
]}
/>
</div>
</div>
</Card>
{/* 数据源/年限/篇数暂时隐藏,默认 PubMed + 近5年 + 全面检索 */}
<Button
type="primary"
@@ -212,7 +110,7 @@ const SetupPanel: React.FC<SetupPanelProps> = ({
block
onClick={handleSubmit}
loading={loading}
disabled={!query.trim() || selectedIds.length === 0}
disabled={!query.trim()}
>
{loading ? LOADING_TEXTS[loadingTextIdx] : '生成检索需求书'}
</Button>

View File

@@ -47,7 +47,7 @@ const ASLModule = () => {
>
<Routes>
<Route path="" element={<ASLLayout />}>
<Route index element={<Navigate to="screening/title/settings" replace />} />
<Route index element={<Navigate to="research/deep" replace />} />
{/* 智能文献检索 V1.x保留兼容 */}
<Route path="research/search" element={<ResearchSearch />} />

View File

@@ -423,30 +423,32 @@ export async function getQcRecordDetail(
// ==================== AI 时间线 ====================
export interface TimelineIssue {
ruleId: string;
ruleName: string;
ruleCategory?: string;
field?: string;
fieldLabel?: string;
eventId?: string;
eventLabel?: string;
formName?: string;
message: string;
severity: string;
actualValue?: string;
expectedValue?: string;
}
export interface TimelineItem {
id: string;
type: 'qc_check';
time: string;
recordId: string;
eventLabel?: string;
formName?: string;
status: string;
triggeredBy: string;
description: string;
details: {
rulesEvaluated: number;
rulesPassed: number;
rulesFailed: number;
issuesSummary: { red: number; yellow: number };
issues?: Array<{
ruleId: string;
ruleName: string;
field?: string;
message: string;
severity: string;
actualValue?: string;
expectedValue?: string;
}>;
issues: TimelineIssue[];
};
}
@@ -459,6 +461,49 @@ export async function getTimeline(
return response.data.data;
}
// ==================== 字段级问题查询 ====================
export interface FieldIssueItem {
id: string;
recordId: string;
eventId: string;
eventLabel?: string;
formName: string;
fieldName: string;
fieldLabel?: string;
ruleCategory: string;
ruleName: string;
ruleId: string;
severity: string;
status: string;
message: string;
actualValue?: string;
expectedValue?: string;
lastQcAt: string;
}
export interface FieldIssuesSummary {
totalIssues: number;
bySeverity: Record<string, number>;
byDimension: Record<string, number>;
}
export interface FieldIssuesResponse {
items: FieldIssueItem[];
total: number;
page: number;
pageSize: number;
summary: FieldIssuesSummary;
}
export async function getFieldIssues(
projectId: string,
params?: { page?: number; pageSize?: number; severity?: string; dimension?: string; recordId?: string }
): Promise<FieldIssuesResponse> {
const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit/field-issues`, { params });
return response.data.data;
}
// ==================== 重大事件 ====================
export interface CriticalEvent {

View File

@@ -1,8 +1,8 @@
/**
* AI 实时工作流水页 (Level 2)
*
* 以 Timeline 展示 Agent 每次质控的完整动作链AI 白盒化
* 显示中文事件名、实际规则数、五层定位详情、最终判定状态
* 以 Timeline 展示 Agent 质控结果,数据来源: qc_field_status (SSOT)
* 按受试者分组展示问题详情,支持按维度分组查看
*/
import React, { useState, useEffect, useCallback } from 'react';
@@ -28,16 +28,20 @@ import {
SyncOutlined,
ClockCircleOutlined,
RobotOutlined,
ApiOutlined,
BellOutlined,
FileSearchOutlined,
} from '@ant-design/icons';
import * as iitProjectApi from '../api/iitProjectApi';
import type { TimelineItem } from '../api/iitProjectApi';
import type { TimelineItem, TimelineIssue } from '../api/iitProjectApi';
import { useIitProject } from '../context/IitProjectContext';
const { Text } = Typography;
const DIMENSION_LABELS: Record<string, string> = {
D1: '入选/排除', D2: '完整性', D3: '准确性', D4: '质疑管理',
D5: '安全性', D6: '方案偏离', D7: '药物管理',
};
const STATUS_DOT: Record<string, { color: string; icon: React.ReactNode; label: string }> = {
PASS: { color: 'green', icon: <CheckCircleOutlined />, label: '通过' },
FAIL: { color: 'red', icon: <CloseCircleOutlined />, label: '严重' },
@@ -66,7 +70,7 @@ const AiStreamPage: React.FC = () => {
try {
const result = await iitProjectApi.getTimeline(projectId, {
page,
pageSize: 30,
pageSize: 20,
date: dateFilter,
});
setItems(result.items);
@@ -85,6 +89,59 @@ const AiStreamPage: React.FC = () => {
fetchData();
};
const issueColumns = [
{
title: '维度',
dataIndex: 'ruleCategory',
width: 90,
render: (v: string) => {
const label = DIMENSION_LABELS[v] || v;
return <Tag color="blue">{v ? `${v} ${label}` : '—'}</Tag>;
},
},
{
title: '规则',
dataIndex: 'ruleName',
width: 160,
render: (v: string, r: TimelineIssue) => <Text>{v || r.ruleId || '—'}</Text>,
},
{
title: '字段',
dataIndex: 'field',
width: 140,
render: (v: string, r: TimelineIssue) => {
const label = r.fieldLabel || v;
return label ? <Text>{label}</Text> : '—';
},
},
{
title: '事件',
dataIndex: 'eventId',
width: 140,
render: (v: string, r: TimelineIssue) => {
const label = r.eventLabel || v;
return label || '—';
},
},
{ title: '问题描述', dataIndex: 'message', ellipsis: true },
{
title: '严重度',
dataIndex: 'severity',
width: 80,
render: (s: string) => (
<Tag color={s === 'critical' ? 'error' : 'warning'}>
{s === 'critical' ? '严重' : '警告'}
</Tag>
),
},
{
title: '实际值',
dataIndex: 'actualValue',
width: 90,
render: (v: string) => v ?? '—',
},
];
const timelineItems = items.map((item) => {
const dotCfg = STATUS_DOT[item.status] || { color: 'gray', icon: <ClockCircleOutlined />, label: '未知' };
const triggerCfg = TRIGGER_TAG[item.triggeredBy] || { color: 'default', label: item.triggeredBy };
@@ -93,42 +150,18 @@ const AiStreamPage: React.FC = () => {
const timeStr = time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const dateStr = time.toLocaleDateString('zh-CN');
const eventLabel = item.eventLabel || '';
const issues = item.details.issues || [];
const issueColumns = [
{
title: '规则',
dataIndex: 'ruleName',
width: 160,
render: (v: string, r: any) => (
<Space size={4}>
<Text>{v || r.ruleId}</Text>
</Space>
),
},
{ title: '字段', dataIndex: 'field', width: 110, render: (v: string) => v ? <Text code>{v}</Text> : '—' },
{ title: '问题描述', dataIndex: 'message', ellipsis: true },
{
title: '严重度',
dataIndex: 'severity',
width: 80,
render: (s: string) => (
<Tag color={s === 'critical' ? 'error' : 'warning'}>
{s === 'critical' ? '严重' : '警告'}
</Tag>
),
},
{
title: '实际值',
dataIndex: 'actualValue',
width: 90,
render: (v: string) => v ?? '—',
},
];
// 按维度分组
const groupedByDimension = issues.reduce<Record<string, TimelineIssue[]>>((acc, iss) => {
const key = iss.ruleCategory || '其他';
if (!acc[key]) acc[key] = [];
acc[key].push(iss);
return acc;
}, {});
return {
color: dotCfg.color as any,
color: dotCfg.color as string,
dot: dotCfg.icon,
children: (
<div style={{ paddingBottom: 8 }}>
@@ -153,29 +186,20 @@ const AiStreamPage: React.FC = () => {
}}>
<Space wrap size={4} style={{ marginBottom: 4 }}>
<RobotOutlined style={{ color: '#3b82f6' }} />
<Text> <Text code>{item.recordId}</Text></Text>
{eventLabel && <Tag color="geekblue">{eventLabel}</Tag>}
<Text> <Text code>{item.recordId}</Text></Text>
</Space>
<div style={{ marginLeft: 20 }}>
<Space size={4}>
<ApiOutlined style={{ color: '#8b5cf6' }} />
<Text> <Text strong>{item.details.rulesEvaluated}</Text> </Text>
<Text type="success"> {item.details.rulesPassed} </Text>
{item.details.rulesFailed > 0 && (
<Text type="danger">/ {item.details.rulesFailed} </Text>
)}
</Space>
</div>
{(red > 0 || yellow > 0) && (
<div style={{ marginLeft: 20, marginTop: 2 }}>
<Space size={4}>
<Space size={8}>
<BellOutlined style={{ color: red > 0 ? '#ef4444' : '#f59e0b' }} />
{red > 0 && <Badge count={red} style={{ backgroundColor: '#ef4444' }} />}
{red > 0 && <Text type="danger"></Text>}
{yellow > 0 && <Badge count={yellow} style={{ backgroundColor: '#f59e0b' }} />}
{yellow > 0 && <Text style={{ color: '#d97706' }}></Text>}
{red > 0 && <><Badge count={red} style={{ backgroundColor: '#ef4444' }} /><Text type="danger"></Text></>}
{yellow > 0 && <><Badge count={yellow} style={{ backgroundColor: '#f59e0b' }} /><Text style={{ color: '#d97706' }}></Text></>}
{Object.entries(groupedByDimension).map(([dim, dimIssues]) => (
<Tag key={dim} color="processing" style={{ fontSize: 10 }}>
{dim} {DIMENSION_LABELS[dim] || ''}: {dimIssues.length}
</Tag>
))}
</Space>
</div>
)}
@@ -200,6 +224,7 @@ const AiStreamPage: React.FC = () => {
size="small"
pagination={false}
columns={issueColumns}
scroll={{ x: 800 }}
/>
),
}]}
@@ -225,7 +250,7 @@ const AiStreamPage: React.FC = () => {
<Space>
<Tag icon={<ThunderboltOutlined />} color="processing"></Tag>
<Badge count={total} overflowCount={9999} style={{ backgroundColor: '#3b82f6' }}>
<Text type="secondary"></Text>
<Text type="secondary"></Text>
</Badge>
</Space>
<Space>
@@ -251,9 +276,9 @@ const AiStreamPage: React.FC = () => {
<Pagination
current={page}
total={total}
pageSize={30}
pageSize={20}
onChange={setPage}
showTotal={(t) => `${t} `}
showTotal={(t) => `${t} 位受试者`}
size="small"
/>
</div>

View File

@@ -27,6 +27,8 @@ import {
Select,
Badge,
Spin,
Modal,
Pagination,
} from 'antd';
import {
FileTextOutlined,
@@ -38,9 +40,10 @@ import {
DatabaseOutlined,
QuestionCircleOutlined,
ExceptionOutlined,
EyeOutlined,
} from '@ant-design/icons';
import * as iitProjectApi from '../api/iitProjectApi';
import type { QcReport, CriticalEvent } from '../api/iitProjectApi';
import type { QcReport, CriticalEvent, FieldIssueItem, FieldIssuesSummary } from '../api/iitProjectApi';
import { useIitProject } from '../context/IitProjectContext';
const EligibilityTable = lazy(() => import('../components/reports/EligibilityTable'));
@@ -62,6 +65,16 @@ const ReportsPage: React.FC = () => {
const [ceTotal, setCeTotal] = useState(0);
const [ceStatusFilter, setCeStatusFilter] = useState<string | undefined>(undefined);
// 问题详情 Modal
const [issueModalOpen, setIssueModalOpen] = useState(false);
const [issueModalSeverity, setIssueModalSeverity] = useState<string | undefined>(undefined);
const [issueModalDimension, setIssueModalDimension] = useState<string | undefined>(undefined);
const [issueItems, setIssueItems] = useState<FieldIssueItem[]>([]);
const [issueTotal, setIssueTotal] = useState(0);
const [issuePage, setIssuePage] = useState(1);
const [issueSummary, setIssueSummary] = useState<FieldIssuesSummary | null>(null);
const [issueLoading, setIssueLoading] = useState(false);
const fetchReport = useCallback(async () => {
if (!projectId) return;
setLoading(true);
@@ -82,6 +95,34 @@ const ReportsPage: React.FC = () => {
useEffect(() => { fetchReport(); }, [fetchReport]);
const fetchIssues = useCallback(async (severity?: string, dimension?: string, pg = 1) => {
if (!projectId) return;
setIssueLoading(true);
try {
const data = await iitProjectApi.getFieldIssues(projectId, {
page: pg,
pageSize: 20,
severity,
dimension,
});
setIssueItems(data.items);
setIssueTotal(data.total);
setIssueSummary(data.summary);
} catch {
setIssueItems([]);
} finally {
setIssueLoading(false);
}
}, [projectId]);
const openIssueModal = (severity?: string) => {
setIssueModalSeverity(severity);
setIssueModalDimension(undefined);
setIssuePage(1);
setIssueModalOpen(true);
fetchIssues(severity, undefined, 1);
};
const handleRefresh = async () => {
if (!projectId) return;
setRefreshing(true);
@@ -117,8 +158,12 @@ const ReportsPage: React.FC = () => {
<Col span={4}><Card size="small">
<Statistic title="通过率" value={summary.passRate} suffix="%" valueStyle={{ color: summary.passRate >= 80 ? '#52c41a' : summary.passRate >= 60 ? '#faad14' : '#ff4d4f' }} />
</Card></Col>
<Col span={4}><Card size="small"><Statistic title="严重问题" value={summary.criticalIssues} prefix={<WarningOutlined />} valueStyle={{ color: '#ff4d4f' }} /></Card></Col>
<Col span={4}><Card size="small"><Statistic title="警告" value={summary.warningIssues} prefix={<AlertOutlined />} valueStyle={{ color: '#faad14' }} /></Card></Col>
<Col span={4}><Card size="small" hoverable onClick={() => openIssueModal('critical')} style={{ cursor: 'pointer' }}>
<Statistic title={<Space size={4}> <EyeOutlined style={{ fontSize: 12, color: '#999' }} /></Space>} value={summary.criticalIssues} prefix={<WarningOutlined />} valueStyle={{ color: '#ff4d4f' }} />
</Card></Col>
<Col span={4}><Card size="small" hoverable onClick={() => openIssueModal('warning')} style={{ cursor: 'pointer' }}>
<Statistic title={<Space size={4}> <EyeOutlined style={{ fontSize: 12, color: '#999' }} /></Space>} value={summary.warningIssues} prefix={<AlertOutlined />} valueStyle={{ color: '#faad14' }} />
</Card></Col>
<Col span={4}><Card size="small"><Statistic title="待处理 Query" value={summary.pendingQueries} /></Card></Col>
</Row>
@@ -223,6 +268,117 @@ const ReportsPage: React.FC = () => {
const lazyFallback = <Spin style={{ display: 'block', margin: '40px auto' }} />;
const DIMENSION_LABELS: Record<string, string> = {
D1: '入选/排除', D2: '完整性', D3: '准确性', D4: '质疑管理',
D5: '安全性', D6: '方案偏离', D7: '药物管理',
};
const issueDetailColumns = [
{ title: '受试者', dataIndex: 'recordId', width: 80, render: (v: string) => <Text strong>{v}</Text> },
{
title: '事件',
dataIndex: 'eventLabel',
width: 130,
render: (v: string, r: FieldIssueItem) => v || r.eventId || '—',
},
{
title: '字段',
dataIndex: 'fieldLabel',
width: 130,
render: (v: string, r: FieldIssueItem) => (v || r.fieldName) ? <Text>{v || r.fieldName}</Text> : '—',
},
{
title: '维度',
dataIndex: 'ruleCategory',
width: 100,
render: (v: string) => <Tag color="blue">{v ? `${v} ${DIMENSION_LABELS[v] || ''}` : '—'}</Tag>,
},
{ title: '规则', dataIndex: 'ruleName', width: 140, ellipsis: true },
{ title: '问题描述', dataIndex: 'message', ellipsis: true },
{
title: '严重度',
dataIndex: 'severity',
width: 80,
render: (s: string) => <Tag color={s === 'critical' ? 'error' : 'warning'}>{s === 'critical' ? '严重' : '警告'}</Tag>,
},
{ title: '实际值', dataIndex: 'actualValue', width: 90, render: (v: string) => v ?? '—' },
{ title: '检出时间', dataIndex: 'lastQcAt', width: 150, render: (d: string) => d ? new Date(d).toLocaleString('zh-CN') : '—' },
];
const issueModal = (
<Modal
title={
<Space>
{issueModalSeverity === 'critical' ? <WarningOutlined style={{ color: '#ff4d4f' }} /> : <AlertOutlined style={{ color: '#faad14' }} />}
{issueModalSeverity === 'critical' ? '严重问题详情' : issueModalSeverity === 'warning' ? '警告详情' : '所有问题详情'}
<Badge count={issueTotal} style={{ backgroundColor: issueModalSeverity === 'critical' ? '#ff4d4f' : '#faad14' }} />
</Space>
}
open={issueModalOpen}
onCancel={() => setIssueModalOpen(false)}
width={1100}
footer={null}
>
<div style={{ marginBottom: 12 }}>
<Space wrap>
<Select
placeholder="按维度筛选"
allowClear
style={{ width: 160 }}
value={issueModalDimension}
onChange={(val) => {
setIssueModalDimension(val);
setIssuePage(1);
fetchIssues(issueModalSeverity, val, 1);
}}
options={
issueSummary
? Object.entries(issueSummary.byDimension).map(([dim, cnt]) => ({
value: dim,
label: `${dim} ${DIMENSION_LABELS[dim] || ''} (${cnt})`,
}))
: []
}
/>
{issueSummary && (
<Space size={12}>
<Text type="secondary">
: <Text type="danger" strong>{issueSummary.bySeverity.critical || 0}</Text>
</Text>
<Text type="secondary">
: <Text style={{ color: '#faad14' }} strong>{issueSummary.bySeverity.warning || 0}</Text>
</Text>
</Space>
)}
</Space>
</div>
<Table
dataSource={issueItems}
rowKey="id"
columns={issueDetailColumns}
size="small"
loading={issueLoading}
pagination={false}
scroll={{ x: 1000, y: 400 }}
/>
<div style={{ textAlign: 'center', marginTop: 12 }}>
<Pagination
current={issuePage}
total={issueTotal}
pageSize={20}
onChange={(pg) => {
setIssuePage(pg);
fetchIssues(issueModalSeverity, issueModalDimension, pg);
}}
showTotal={(t) => `${t}`}
size="small"
/>
</div>
</Modal>
);
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
@@ -282,6 +438,7 @@ const ReportsPage: React.FC = () => {
]}
/>
</Card>
{issueModal}
</div>
);
};

View File

@@ -84,7 +84,7 @@ const DashboardPage: React.FC = () => {
const [selectedTypeId, setSelectedTypeId] = useState<KBType | null>(null);
// 表单数据
const [formData, setFormData] = useState({ name: '', department: 'Cardiology' });
const [formData, setFormData] = useState({ name: '', department: 'General' });
const [files, setFiles] = useState<any[]>([]);
// 新增创建知识库后保存ID用于Step3上传文档
const [createdKbId, setCreatedKbId] = useState<string | null>(null);
@@ -141,7 +141,7 @@ const DashboardPage: React.FC = () => {
const handleCreateOpen = () => {
setCreateStep(1);
setSelectedTypeId(null);
setFormData({ name: '', department: 'Cardiology' });
setFormData({ name: '', department: 'General' });
setFiles([]);
setCreatedKbId(null);
setUploadedCount(0);
@@ -400,19 +400,7 @@ const DashboardPage: React.FC = () => {
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2"> ( AI )</label>
<select
className="w-full px-4 py-3 border border-gray-300 rounded-lg bg-white outline-none focus:ring-2 focus:ring-blue-500 text-base"
value={formData.department}
onChange={(e) => setFormData({...formData, department: e.target.value})}
>
<option value="Cardiology"></option>
<option value="Neurology"></option>
<option value="Oncology"></option>
<option value="General"></option>
</select>
</div>
{/* 科室选择暂时隐藏,默认使用 General */}
</div>
</div>
)}