feat(iit): QC deep fix + V3.1 architecture plan + project member management
QC System Deep Fix: - HardRuleEngine: add null tolerance + field availability pre-check (skipped status) - SkillRunner: baseline data merge for follow-up events + field availability check - QcReportService: record-level pass rate calculation + accurate LLM XML report - iitBatchController: legacy log cleanup (eventId=null) + upsert RecordSummary - seed-iit-qc-rules: null/empty string tolerance + applicableEvents config V3.1 Architecture Design (docs only, no code changes): - QC engine V3.1 plan: 5-level data structure (CDISC ODM) + D1-D7 dimensions - Three-batch implementation strategy (A: foundation, B: bubbling, C: new engines) - Architecture team review: 4 whitepapers reviewed + feedback doc + 4 critical suggestions - CRA Agent strategy roadmap + CRA 4-tool explanation doc for clinical experts Project Member Management: - Cross-tenant member search and assignment (remove tenant restriction) - IIT project detail page enhancement with tabbed layout (KB + members) - IitProjectContext for business-side project selection - System-KB route access control adjustment for project operators Frontend: - AdminLayout sidebar menu restructure - IitLayout with project context provider - IitMemberManagePage new component - Business-side pages adapt to project context Prisma: - 2 new migrations (user-project RBAC + is_demo flag) - Schema updates for project member management Made-with: Cursor
This commit is contained in:
@@ -67,79 +67,86 @@ export class IitBatchController {
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 统计(按 record+event 组合)
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
let warningCount = 0;
|
||||
let uncertainCount = 0;
|
||||
|
||||
const uniqueRecords = new Set<string>();
|
||||
// 3. V3.2: 按 record 级别聚合状态(每个 record 取最严重状态)
|
||||
const statusPriority: Record<string, number> = { 'FAIL': 3, 'WARNING': 2, 'UNCERTAIN': 1, 'PASS': 0 };
|
||||
const recordWorstStatus = new Map<string, string>();
|
||||
|
||||
for (const result of results) {
|
||||
uniqueRecords.add(result.recordId);
|
||||
|
||||
if (result.overallStatus === 'PASS') passCount++;
|
||||
else if (result.overallStatus === 'FAIL') failCount++;
|
||||
else if (result.overallStatus === 'WARNING') warningCount++;
|
||||
else uncertainCount++;
|
||||
|
||||
// 更新录入汇总表(取最差状态)
|
||||
const existingSummary = await prisma.iitRecordSummary.findUnique({
|
||||
where: { projectId_recordId: { projectId, recordId: result.recordId } }
|
||||
});
|
||||
|
||||
const statusPriority: Record<string, number> = { 'FAIL': 3, 'WARNING': 2, 'UNCERTAIN': 1, 'PASS': 0 };
|
||||
const currentPriority = statusPriority[result.overallStatus] || 0;
|
||||
const existingPriority = statusPriority[existingSummary?.latestQcStatus || 'PASS'] || 0;
|
||||
|
||||
// 只更新为更严重的状态
|
||||
if (!existingSummary || currentPriority > existingPriority) {
|
||||
await prisma.iitRecordSummary.upsert({
|
||||
where: { projectId_recordId: { projectId, recordId: result.recordId } },
|
||||
create: {
|
||||
projectId,
|
||||
recordId: result.recordId,
|
||||
lastUpdatedAt: new Date(),
|
||||
latestQcStatus: result.overallStatus,
|
||||
latestQcAt: new Date(),
|
||||
formStatus: {},
|
||||
updateCount: 1
|
||||
},
|
||||
update: {
|
||||
latestQcStatus: result.overallStatus,
|
||||
latestQcAt: new Date()
|
||||
}
|
||||
});
|
||||
const existing = recordWorstStatus.get(result.recordId);
|
||||
const currentPrio = statusPriority[result.overallStatus] ?? 0;
|
||||
const existingPrio = existing ? (statusPriority[existing] ?? 0) : -1;
|
||||
if (currentPrio > existingPrio) {
|
||||
recordWorstStatus.set(result.recordId, result.overallStatus);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 更新项目统计表
|
||||
// V3.2: 用本次批量质控结果更新 record_summary(覆盖旧状态)
|
||||
for (const [recordId, worstStatus] of recordWorstStatus) {
|
||||
await prisma.iitRecordSummary.upsert({
|
||||
where: { projectId_recordId: { projectId, recordId } },
|
||||
create: {
|
||||
projectId,
|
||||
recordId,
|
||||
lastUpdatedAt: new Date(),
|
||||
latestQcStatus: worstStatus,
|
||||
latestQcAt: new Date(),
|
||||
formStatus: {},
|
||||
updateCount: 1
|
||||
},
|
||||
update: {
|
||||
latestQcStatus: worstStatus,
|
||||
latestQcAt: new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// V3.2: 清理该项目旧版本日志(event_id 为 NULL 的遗留数据)
|
||||
const deletedLegacy = await prisma.iitQcLog.deleteMany({
|
||||
where: { projectId, eventId: null }
|
||||
});
|
||||
if (deletedLegacy.count > 0) {
|
||||
logger.info('🧹 清理旧版质控日志', { projectId, deletedCount: deletedLegacy.count });
|
||||
}
|
||||
|
||||
// V3.2: record 级别统计
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
let warningCount = 0;
|
||||
|
||||
for (const status of recordWorstStatus.values()) {
|
||||
if (status === 'PASS') passCount++;
|
||||
else if (status === 'FAIL') failCount++;
|
||||
else warningCount++;
|
||||
}
|
||||
|
||||
const totalRecords = recordWorstStatus.size;
|
||||
|
||||
// 4. 更新项目统计表(record 级别)
|
||||
await prisma.iitQcProjectStats.upsert({
|
||||
where: { projectId },
|
||||
create: {
|
||||
projectId,
|
||||
totalRecords: uniqueRecords.size,
|
||||
totalRecords,
|
||||
passedRecords: passCount,
|
||||
failedRecords: failCount,
|
||||
warningRecords: warningCount + uncertainCount
|
||||
warningRecords: warningCount
|
||||
},
|
||||
update: {
|
||||
totalRecords: uniqueRecords.size,
|
||||
totalRecords,
|
||||
passedRecords: passCount,
|
||||
failedRecords: failCount,
|
||||
warningRecords: warningCount + uncertainCount
|
||||
warningRecords: warningCount
|
||||
}
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
logger.info('✅ 事件级全量质控完成', {
|
||||
projectId,
|
||||
uniqueRecords: uniqueRecords.size,
|
||||
totalRecords,
|
||||
totalEventCombinations: results.length,
|
||||
passCount,
|
||||
failCount,
|
||||
warningCount,
|
||||
uncertainCount,
|
||||
durationMs
|
||||
});
|
||||
|
||||
@@ -147,13 +154,14 @@ export class IitBatchController {
|
||||
success: true,
|
||||
message: '事件级全量质控完成',
|
||||
stats: {
|
||||
totalRecords: uniqueRecords.size,
|
||||
totalRecords,
|
||||
totalEventCombinations: results.length,
|
||||
passed: passCount,
|
||||
failed: failCount,
|
||||
warnings: warningCount,
|
||||
uncertain: uncertainCount,
|
||||
passRate: `${((passCount / results.length) * 100).toFixed(1)}%`
|
||||
passRate: totalRecords > 0
|
||||
? `${((passCount / totalRecords) * 100).toFixed(1)}%`
|
||||
: '0%'
|
||||
},
|
||||
durationMs
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ interface ProjectIdParams {
|
||||
interface ListProjectsQuery {
|
||||
status?: string;
|
||||
search?: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
interface TestConnectionBody {
|
||||
@@ -29,6 +30,27 @@ interface LinkKbBody {
|
||||
|
||||
// ==================== 控制器函数 ====================
|
||||
|
||||
/**
|
||||
* 获取租户选项列表(供创建项目时选择租户)
|
||||
*/
|
||||
export async function listTenantOptions(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const tenants = await prisma.tenants.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
select: { id: true, code: true, name: true, type: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return reply.send({ success: true, data: tenants });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
logger.error('获取租户列表失败', { error: msg });
|
||||
return reply.status(500).send({ success: false, error: msg });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目列表
|
||||
*/
|
||||
@@ -37,9 +59,17 @@ export async function listProjects(
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { status, search } = request.query;
|
||||
const { status, search, tenantId: queryTenantId } = request.query;
|
||||
const user = request.user;
|
||||
const service = getIitProjectService(prisma);
|
||||
const projects = await service.listProjects({ status, search });
|
||||
|
||||
// Phase 3 租户隔离:非 SUPER_ADMIN/IIT_OPERATOR 只能看自己租户的项目
|
||||
let effectiveTenantId = queryTenantId;
|
||||
if (user && user.role !== 'SUPER_ADMIN' && user.role !== 'IIT_OPERATOR') {
|
||||
effectiveTenantId = user.tenantId;
|
||||
}
|
||||
|
||||
const projects = await service.listProjects({ status, search, tenantId: effectiveTenantId });
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
@@ -92,11 +122,12 @@ export async function getProject(
|
||||
* 创建项目
|
||||
*/
|
||||
export async function createProject(
|
||||
request: FastifyRequest<{ Body: CreateProjectInput }>,
|
||||
request: FastifyRequest<{ Body: CreateProjectInput & { tenantId?: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const input = request.body;
|
||||
const user = request.user;
|
||||
|
||||
// 验证必填字段
|
||||
if (!input.name) {
|
||||
@@ -113,6 +144,17 @@ export async function createProject(
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 3: 自动绑定 tenantId
|
||||
// SUPER_ADMIN/IIT_OPERATOR: 可以指定 tenantId(创建时选择为哪个客户创建)
|
||||
// PHARMA_ADMIN/HOSPITAL_ADMIN: 自动绑定自己的 tenantId
|
||||
if (user) {
|
||||
if (user.role === 'SUPER_ADMIN' || user.role === 'IIT_OPERATOR') {
|
||||
// 可以使用请求体中指定的 tenantId,或不指定
|
||||
} else {
|
||||
input.tenantId = user.tenantId;
|
||||
}
|
||||
}
|
||||
|
||||
const service = getIitProjectService(prisma);
|
||||
const project = await service.createProject(input);
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ import { FastifyInstance } from 'fastify';
|
||||
import * as controller from './iitProjectController.js';
|
||||
|
||||
export async function iitProjectRoutes(fastify: FastifyInstance) {
|
||||
// ==================== 租户选项(创建项目时选择) ====================
|
||||
fastify.get('/tenant-options', controller.listTenantOptions);
|
||||
|
||||
// ==================== 项目 CRUD ====================
|
||||
|
||||
// 获取项目列表
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface CreateProjectInput {
|
||||
redcapApiToken: string;
|
||||
fieldMappings?: Record<string, unknown>;
|
||||
knowledgeBaseId?: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProjectInput {
|
||||
@@ -28,6 +29,7 @@ export interface UpdateProjectInput {
|
||||
fieldMappings?: Record<string, unknown>;
|
||||
knowledgeBaseId?: string;
|
||||
status?: string;
|
||||
isDemo?: boolean;
|
||||
}
|
||||
|
||||
export interface TestConnectionResult {
|
||||
@@ -41,6 +43,7 @@ export interface TestConnectionResult {
|
||||
export interface ProjectListFilters {
|
||||
status?: string;
|
||||
search?: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
// ==================== 服务实现 ====================
|
||||
@@ -60,6 +63,10 @@ export class IitProjectService {
|
||||
where.status = filters.status;
|
||||
}
|
||||
|
||||
if (filters?.tenantId) {
|
||||
where.tenantId = filters.tenantId;
|
||||
}
|
||||
|
||||
if (filters?.search) {
|
||||
where.OR = [
|
||||
{ name: { contains: filters.search, mode: 'insensitive' } },
|
||||
@@ -77,10 +84,14 @@ export class IitProjectService {
|
||||
redcapProjectId: true,
|
||||
redcapUrl: true,
|
||||
knowledgeBaseId: true,
|
||||
tenantId: true,
|
||||
status: true,
|
||||
lastSyncAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
tenant: {
|
||||
select: { id: true, name: true, code: true },
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
userMappings: true,
|
||||
@@ -91,6 +102,8 @@ export class IitProjectService {
|
||||
|
||||
return projects.map((p) => ({
|
||||
...p,
|
||||
tenantName: p.tenant?.name || null,
|
||||
tenantCode: p.tenant?.code || null,
|
||||
userMappingCount: p._count.userMappings,
|
||||
}));
|
||||
}
|
||||
@@ -102,6 +115,7 @@ export class IitProjectService {
|
||||
const project = await this.prisma.iitProject.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
include: {
|
||||
tenant: { select: { id: true, name: true, code: true } },
|
||||
userMappings: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -173,8 +187,9 @@ export class IitProjectService {
|
||||
redcapUrl: input.redcapUrl,
|
||||
redcapProjectId: input.redcapProjectId,
|
||||
redcapApiToken: input.redcapApiToken,
|
||||
fieldMappings: input.fieldMappings || {},
|
||||
fieldMappings: (input.fieldMappings || {}) as any,
|
||||
knowledgeBaseId: input.knowledgeBaseId,
|
||||
tenantId: input.tenantId || null,
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
@@ -207,9 +222,10 @@ export class IitProjectService {
|
||||
redcapUrl: input.redcapUrl,
|
||||
redcapProjectId: input.redcapProjectId,
|
||||
redcapApiToken: input.redcapApiToken,
|
||||
fieldMappings: input.fieldMappings,
|
||||
fieldMappings: input.fieldMappings as any,
|
||||
knowledgeBaseId: input.knowledgeBaseId,
|
||||
status: input.status,
|
||||
isDemo: input.isDemo,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -97,23 +97,21 @@ export async function createUserMapping(
|
||||
const { projectId } = request.params;
|
||||
const input = request.body;
|
||||
|
||||
// 验证必填字段 - 只有企业微信用户 ID 是必填的
|
||||
if (!input.wecomUserId) {
|
||||
// 验证:至少提供 userId(平台用户)或 wecomUserId(企业微信用户)
|
||||
if (!input.userId && !input.wecomUserId) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: '请输入企业微信用户 ID',
|
||||
error: '请选择平台用户或输入企业微信用户 ID',
|
||||
});
|
||||
}
|
||||
|
||||
// 如果没有提供 systemUserId,使用 wecomUserId 作为默认值
|
||||
const fallbackId = input.wecomUserId || input.userId || 'unknown';
|
||||
if (!input.systemUserId) {
|
||||
input.systemUserId = input.wecomUserId;
|
||||
input.systemUserId = fallbackId;
|
||||
}
|
||||
// 如果没有提供 redcapUsername,使用 wecomUserId 作为默认值
|
||||
if (!input.redcapUsername) {
|
||||
input.redcapUsername = input.wecomUserId;
|
||||
input.redcapUsername = fallbackId;
|
||||
}
|
||||
// 如果没有提供 role,默认为 PI
|
||||
if (!input.role) {
|
||||
input.role = 'PI';
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface CreateUserMappingInput {
|
||||
systemUserId: string;
|
||||
redcapUsername: string;
|
||||
wecomUserId?: string;
|
||||
userId?: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@@ -19,6 +20,7 @@ export interface UpdateUserMappingInput {
|
||||
systemUserId?: string;
|
||||
redcapUsername?: string;
|
||||
wecomUserId?: string;
|
||||
userId?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
@@ -54,6 +56,9 @@ export class IitUserMappingService {
|
||||
|
||||
const mappings = await this.prisma.iitUserMapping.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: { select: { id: true, name: true, phone: true, email: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
@@ -106,6 +111,7 @@ export class IitUserMappingService {
|
||||
systemUserId: input.systemUserId,
|
||||
redcapUsername: input.redcapUsername,
|
||||
wecomUserId: input.wecomUserId,
|
||||
userId: input.userId || null,
|
||||
role: input.role,
|
||||
},
|
||||
});
|
||||
@@ -132,6 +138,7 @@ export class IitUserMappingService {
|
||||
systemUserId: input.systemUserId,
|
||||
redcapUsername: input.redcapUsername,
|
||||
wecomUserId: input.wecomUserId,
|
||||
userId: input.userId,
|
||||
role: input.role,
|
||||
},
|
||||
});
|
||||
@@ -164,6 +171,7 @@ export class IitUserMappingService {
|
||||
*/
|
||||
getRoleOptions() {
|
||||
return [
|
||||
{ value: 'PM', label: '项目管理员 (PM)' },
|
||||
{ value: 'PI', label: '主要研究者 (PI)' },
|
||||
{ value: 'Sub-I', label: '次要研究者 (Sub-I)' },
|
||||
{ value: 'CRC', label: '临床研究协调员 (CRC)' },
|
||||
|
||||
@@ -50,7 +50,8 @@ export async function getUserQueryScope(
|
||||
switch (userRole) {
|
||||
case 'SUPER_ADMIN':
|
||||
case 'PROMPT_ENGINEER':
|
||||
return {}; // 无限制
|
||||
case 'IIT_OPERATOR':
|
||||
return {}; // 无限制(IIT 项目跨机构协作,需搜索所有租户用户)
|
||||
case 'HOSPITAL_ADMIN':
|
||||
case 'PHARMA_ADMIN':
|
||||
return { tenantId }; // 只能查看本租户
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
import { authenticate, requireRoles } from '../../../common/auth/auth.middleware.js';
|
||||
|
||||
export async function systemKbRoutes(fastify: FastifyInstance) {
|
||||
// 所有路由都需要认证 + SUPER_ADMIN 或 ADMIN 角色
|
||||
const preHandler = [authenticate, requireRoles('SUPER_ADMIN', 'ADMIN')];
|
||||
// 认证 + 角色守卫:SUPER_ADMIN / ADMIN / IIT_OPERATOR 均可完整操作知识库
|
||||
const preHandler = [authenticate, requireRoles('SUPER_ADMIN', 'ADMIN', 'IIT_OPERATOR')];
|
||||
|
||||
// ==================== 知识库 CRUD ====================
|
||||
|
||||
|
||||
@@ -298,7 +298,7 @@ export class SystemKbService {
|
||||
|
||||
try {
|
||||
// 3. 生成 OSS 存储路径并上传
|
||||
const ossKey = this.generateOssKey(kbId, doc.id, filename);
|
||||
const ossKey = this.generateOssKey(kbId, doc.id, filename, kb.category);
|
||||
const ossUrl = await storage.upload(ossKey, fileBuffer);
|
||||
|
||||
// 4. 更新 file_path
|
||||
@@ -473,15 +473,15 @@ export class SystemKbService {
|
||||
/**
|
||||
* 生成 OSS 存储路径
|
||||
*
|
||||
* 格式:system/knowledge-bases/{kbId}/{docId}.{ext}
|
||||
*
|
||||
* @param kbId - 知识库 ID
|
||||
* @param docId - 文档 ID
|
||||
* @param filename - 原始文件名(用于获取扩展名)
|
||||
* 系统知识库: system/knowledge-bases/{kbId}/{docId}.{ext}
|
||||
* IIT 项目知识库: system/iit-knowledge-bases/{kbId}/{docId}.{ext}
|
||||
*/
|
||||
private generateOssKey(kbId: string, docId: string, filename: string): string {
|
||||
private generateOssKey(kbId: string, docId: string, filename: string, category?: string | null): string {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
return `system/knowledge-bases/${kbId}/${docId}${ext}`;
|
||||
const prefix = category === 'iit_project'
|
||||
? 'system/iit-knowledge-bases'
|
||||
: 'system/knowledge-bases';
|
||||
return `${prefix}/${kbId}/${docId}${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface RuleResult {
|
||||
ruleName: string;
|
||||
field: string | string[];
|
||||
passed: boolean;
|
||||
skipped?: boolean; // V3.2: 字段缺失时标记为跳过
|
||||
message: string; // 基础消息
|
||||
llmMessage?: string; // V2.1: LLM 友好的自包含消息
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
@@ -231,29 +232,50 @@ export class HardRuleEngine {
|
||||
return records.map(r => this.execute(r.recordId, r.data));
|
||||
}
|
||||
|
||||
/**
|
||||
* V3.2: 检查规则所需字段是否在数据中可用
|
||||
*
|
||||
* 当所有字段均为 null/undefined/空字符串时返回 false
|
||||
*/
|
||||
private isFieldAvailable(field: string | string[], data: Record<string, any>): boolean {
|
||||
const fields = Array.isArray(field) ? field : [field];
|
||||
return fields.some(f => {
|
||||
const val = data[f];
|
||||
return val !== undefined && val !== null && val !== '';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行单条规则
|
||||
*
|
||||
* V2.1 优化:生成自包含的 LLM 友好消息
|
||||
* V3.2: 字段缺失时标记为 SKIP 而非 FAIL
|
||||
*/
|
||||
private executeRule(rule: QCRule, data: Record<string, any>): RuleResult {
|
||||
try {
|
||||
// 获取字段值
|
||||
const fieldValue = this.getFieldValue(rule.field, data);
|
||||
|
||||
// 执行 JSON Logic
|
||||
const passed = jsonLogic.apply(rule.logic, data) as boolean;
|
||||
// V3.2: 字段缺失预检查 - 缺失时跳过而非判定失败
|
||||
if (!this.isFieldAvailable(rule.field, data)) {
|
||||
return {
|
||||
ruleId: rule.id,
|
||||
ruleName: rule.name,
|
||||
field: rule.field,
|
||||
passed: true,
|
||||
skipped: true,
|
||||
message: '字段缺失,跳过检查',
|
||||
severity: rule.severity,
|
||||
category: rule.category,
|
||||
actualValue: fieldValue,
|
||||
};
|
||||
}
|
||||
|
||||
// V2.1: 解析期望值(从 JSON Logic 中提取)
|
||||
const passed = jsonLogic.apply(rule.logic, data) as boolean;
|
||||
const expectedValue = this.extractExpectedValue(rule.logic);
|
||||
const expectedCondition = this.describeLogic(rule.logic);
|
||||
|
||||
// V2.1: 构建自包含的 LLM 友好消息
|
||||
const llmMessage = passed
|
||||
? '通过'
|
||||
: this.buildLlmMessage(rule, fieldValue, expectedValue);
|
||||
|
||||
// V2.1: 构建结构化证据
|
||||
const evidence = {
|
||||
value: fieldValue,
|
||||
threshold: expectedValue,
|
||||
|
||||
@@ -273,8 +273,9 @@ export class SkillRunner {
|
||||
/**
|
||||
* 获取要处理的记录(事件级别)
|
||||
*
|
||||
* V3.1: 返回事件级数据,每个 record+event 作为独立单元
|
||||
* 不再合并事件数据,确保每个访视独立质控
|
||||
* V3.2: 将每个 record 的第一个 event(筛选/基线)数据
|
||||
* 合并到后续 event 中,确保纳入/排除规则的字段在所有事件可用。
|
||||
* 后续 event 自身的字段值优先(覆盖基线值)。
|
||||
*/
|
||||
private async getRecordsToProcess(
|
||||
options?: SkillRunnerOptions
|
||||
@@ -287,19 +288,54 @@ export class SkillRunner {
|
||||
}>> {
|
||||
const adapter = await this.initRedcapAdapter();
|
||||
|
||||
// V3.1: 使用 getAllRecordsByEvent 获取事件级数据
|
||||
const eventRecords = await adapter.getAllRecordsByEvent({
|
||||
recordId: options?.recordId,
|
||||
eventName: options?.eventName,
|
||||
});
|
||||
|
||||
return eventRecords.map(r => ({
|
||||
recordId: r.recordId,
|
||||
eventName: r.eventName,
|
||||
eventLabel: r.eventLabel,
|
||||
forms: r.forms,
|
||||
data: r.data,
|
||||
}));
|
||||
// V3.2: 按 recordId 分组,找到每个 record 的第一个 event 作为基线
|
||||
const recordGroups = new Map<string, typeof eventRecords>();
|
||||
for (const r of eventRecords) {
|
||||
if (!recordGroups.has(r.recordId)) {
|
||||
recordGroups.set(r.recordId, []);
|
||||
}
|
||||
recordGroups.get(r.recordId)!.push(r);
|
||||
}
|
||||
|
||||
const results: Array<{
|
||||
recordId: string;
|
||||
eventName: string;
|
||||
eventLabel: string;
|
||||
forms: string[];
|
||||
data: Record<string, any>;
|
||||
}> = [];
|
||||
|
||||
for (const [recordId, events] of recordGroups) {
|
||||
const baselineData = events[0]?.data || {};
|
||||
|
||||
for (const event of events) {
|
||||
// 基线字段作为底层,当前事件字段覆盖(当前事件有值的字段优先)
|
||||
const mergedData: Record<string, any> = { ...baselineData };
|
||||
for (const [key, val] of Object.entries(event.data)) {
|
||||
if (val !== undefined && val !== null && val !== '') {
|
||||
mergedData[key] = val;
|
||||
}
|
||||
}
|
||||
// 保持 REDCap 元字段为当前事件的值
|
||||
mergedData.redcap_event_name = event.data.redcap_event_name;
|
||||
mergedData.record_id = event.data.record_id;
|
||||
|
||||
results.push({
|
||||
recordId: event.recordId,
|
||||
eventName: event.eventName,
|
||||
eventLabel: event.eventLabel,
|
||||
forms: event.forms,
|
||||
data: mergedData,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -542,6 +578,17 @@ export class SkillRunner {
|
||||
*
|
||||
* V2.1 优化:添加 expectedValue, llmMessage, evidence 字段
|
||||
*/
|
||||
/**
|
||||
* V3.2: 检查规则所需字段是否在数据中可用
|
||||
*/
|
||||
private isFieldAvailable(field: string | string[], data: Record<string, any>): boolean {
|
||||
const fields = Array.isArray(field) ? field : [field];
|
||||
return fields.some(f => {
|
||||
const val = data[f];
|
||||
return val !== undefined && val !== null && val !== '';
|
||||
});
|
||||
}
|
||||
|
||||
private executeHardRulesDirectly(
|
||||
rules: QCRule[],
|
||||
recordId: string,
|
||||
@@ -553,16 +600,22 @@ export class SkillRunner {
|
||||
|
||||
for (const rule of rules) {
|
||||
try {
|
||||
// V3.2: 字段缺失预检查 - 缺失时跳过而非判定失败
|
||||
if (!this.isFieldAvailable(rule.field, data)) {
|
||||
logger.debug('[SkillRunner] Skipping rule - field not available', {
|
||||
ruleId: rule.id,
|
||||
field: rule.field,
|
||||
recordId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const passed = jsonLogic.apply(rule.logic, data);
|
||||
|
||||
if (!passed) {
|
||||
const severity = rule.severity === 'error' ? 'critical' : 'warning';
|
||||
const actualValue = this.getFieldValue(rule.field, data);
|
||||
|
||||
// V2.1: 提取期望值
|
||||
const expectedValue = this.extractExpectedValue(rule.logic);
|
||||
|
||||
// V2.1: 构建自包含的 LLM 友好消息
|
||||
const llmMessage = this.buildLlmMessage(rule, actualValue, expectedValue);
|
||||
|
||||
issues.push({
|
||||
@@ -570,11 +623,11 @@ export class SkillRunner {
|
||||
ruleName: rule.name,
|
||||
field: rule.field,
|
||||
message: rule.message,
|
||||
llmMessage, // V2.1: 自包含消息
|
||||
llmMessage,
|
||||
severity,
|
||||
actualValue,
|
||||
expectedValue, // V2.1: 期望值
|
||||
evidence: { // V2.1: 结构化证据
|
||||
expectedValue,
|
||||
evidence: {
|
||||
value: actualValue,
|
||||
threshold: expectedValue,
|
||||
unit: (rule.metadata as any)?.unit,
|
||||
|
||||
@@ -468,6 +468,39 @@ export async function registerIitRoutes(fastify: FastifyInstance) {
|
||||
// =============================================
|
||||
const { getChatOrchestrator } = await import('../services/ChatOrchestrator.js');
|
||||
|
||||
/** 路由层兜底:过滤 LLM 泄漏的 DSML/XML 工具调用标签 */
|
||||
function sanitizeLlmReply(text: string): string {
|
||||
// 策略1:关键词检测 + 截断(最可靠)
|
||||
// 如果文本包含 "DSML" 关键词,截取到第一个 DSML 出现之前的内容
|
||||
if (text.includes('DSML')) {
|
||||
// 找到包含 DSML 的第一个 < 符号位置
|
||||
const dsmlIdx = text.indexOf('DSML');
|
||||
// 向前搜索最近的 < 符号
|
||||
let cutStart = text.lastIndexOf('<', dsmlIdx);
|
||||
if (cutStart === -1) cutStart = dsmlIdx;
|
||||
const before = text.substring(0, cutStart).trim();
|
||||
// 尝试找到最后一个 > 后的文本(DSML 块之后可能还有正常内容)
|
||||
const lastClose = text.lastIndexOf('>');
|
||||
const after = lastClose > dsmlIdx ? text.substring(lastClose + 1).trim() : '';
|
||||
const result = (before + (after ? '\n' + after : '')).trim();
|
||||
logger.info('[sanitizeLlmReply] Stripped DSML via keyword detection', {
|
||||
originalLen: text.length,
|
||||
cleanLen: result.length,
|
||||
cutStart,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// 策略2:正则兜底(处理非 DSML 格式的工具调用标签)
|
||||
let cleaned = text;
|
||||
cleaned = cleaned.replace(/<\s*\/?\s*function_calls?\s*>[\s\S]*?<\s*\/\s*function_calls?\s*>/gi, '');
|
||||
cleaned = cleaned.replace(/<\s*\/?\s*function_calls?\s*>/gi, '');
|
||||
cleaned = cleaned.replace(/<\s*\/?\s*invoke\s*[^>]*>/gi, '');
|
||||
cleaned = cleaned.replace(/<\s*\/?\s*parameter\s*[^>]*>/gi, '');
|
||||
cleaned = cleaned.replace(/<\s*\/?\s*tool_call\s*[^>]*>/gi, '');
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
fastify.post(
|
||||
'/api/v1/iit/chat',
|
||||
{
|
||||
@@ -480,15 +513,6 @@ export async function registerIitRoutes(fastify: FastifyInstance) {
|
||||
userId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
reply: { type: 'string' },
|
||||
duration: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request: any, reply) => {
|
||||
@@ -497,15 +521,22 @@ export async function registerIitRoutes(fastify: FastifyInstance) {
|
||||
const { message, userId } = request.body;
|
||||
const uid = userId || request.user?.id || 'web-user';
|
||||
const orchestrator = await getChatOrchestrator();
|
||||
const replyText = await orchestrator.handleMessage(uid, message);
|
||||
const rawReply = await orchestrator.handleMessage(uid, message);
|
||||
const cleanReply = sanitizeLlmReply(rawReply);
|
||||
|
||||
logger.info('[WebChat] Reply sanitized', {
|
||||
hadDsml: rawReply !== cleanReply,
|
||||
rawLen: rawReply.length,
|
||||
cleanLen: cleanReply.length,
|
||||
});
|
||||
|
||||
return reply.code(200).send({
|
||||
reply: replyText,
|
||||
reply: cleanReply || '抱歉,我暂时无法回答这个问题。',
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('Web chat failed', { error: error.message });
|
||||
return reply.code(500).send({
|
||||
return (reply as any).code(500).send({
|
||||
reply: '系统处理出错,请稍后重试。',
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
@@ -514,6 +545,75 @@ export async function registerIitRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
logger.info('Registered route: POST /api/v1/iit/chat');
|
||||
|
||||
// =============================================
|
||||
// My Projects API(当前用户关联的 IIT 项目)
|
||||
// =============================================
|
||||
const { authenticate } = await import('../../../common/auth/auth.middleware.js');
|
||||
const { PrismaClient } = await import('@prisma/client');
|
||||
const prismaForMyProjects = new PrismaClient();
|
||||
|
||||
fastify.get(
|
||||
'/api/v1/iit/my-projects',
|
||||
{ preHandler: [authenticate] },
|
||||
async (request: any, reply) => {
|
||||
try {
|
||||
const userId = request.user?.userId;
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const mappings = await prismaForMyProjects.iitUserMapping.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
status: true,
|
||||
redcapProjectId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const projects = mappings
|
||||
.filter(m => m.project.status === 'active' && !('deletedAt' in m.project && (m.project as any).deletedAt))
|
||||
.map(m => ({
|
||||
id: m.project.id,
|
||||
name: m.project.name,
|
||||
description: m.project.description,
|
||||
status: m.project.status,
|
||||
redcapProjectId: m.project.redcapProjectId,
|
||||
createdAt: m.project.createdAt,
|
||||
myRole: m.role,
|
||||
isDemo: false,
|
||||
}));
|
||||
|
||||
if (projects.length === 0) {
|
||||
const demoProjects = await prismaForMyProjects.iitProject.findMany({
|
||||
where: { isDemo: true, status: 'active', deletedAt: null },
|
||||
select: {
|
||||
id: true, name: true, description: true, status: true,
|
||||
redcapProjectId: true, createdAt: true, isDemo: true,
|
||||
},
|
||||
});
|
||||
const demoList = demoProjects.map(p => ({
|
||||
...p, myRole: 'VIEWER', isDemo: true,
|
||||
}));
|
||||
return reply.code(200).send({ success: true, data: demoList });
|
||||
}
|
||||
|
||||
return reply.code(200).send({ success: true, data: projects });
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get my projects', { error: error.message });
|
||||
return reply.code(500).send({ success: false, error: '获取项目列表失败' });
|
||||
}
|
||||
}
|
||||
);
|
||||
logger.info('Registered route: GET /api/v1/iit/my-projects');
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,30 @@ const prisma = new PrismaClient();
|
||||
const MAX_ROUNDS = 3;
|
||||
const DEFAULT_MODEL = 'deepseek-v3' as const;
|
||||
|
||||
/**
|
||||
* 过滤 LLM 输出中泄漏的 DSML / XML 工具调用标签。
|
||||
* DeepSeek 有时会在 content 中混入 DSML function_calls 等标记。
|
||||
* 使用关键词检测而非纯正则,因为 LLM 输出可能含有不可见 Unicode 字符。
|
||||
*/
|
||||
function stripToolCallXml(text: string): string {
|
||||
if (text.includes('DSML')) {
|
||||
const dsmlIdx = text.indexOf('DSML');
|
||||
let cutStart = text.lastIndexOf('<', dsmlIdx);
|
||||
if (cutStart === -1) cutStart = dsmlIdx;
|
||||
const before = text.substring(0, cutStart).trim();
|
||||
const lastClose = text.lastIndexOf('>');
|
||||
const after = lastClose > dsmlIdx ? text.substring(lastClose + 1).trim() : '';
|
||||
return (before + (after ? '\n' + after : '')).trim();
|
||||
}
|
||||
if (text.includes('function_calls')) {
|
||||
const idx = text.indexOf('function_calls');
|
||||
let cutStart = text.lastIndexOf('<', idx);
|
||||
if (cutStart === -1) cutStart = idx;
|
||||
return text.substring(0, cutStart).trim();
|
||||
}
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `You are a CRA Agent (Clinical Research Associate AI) monitoring an IIT clinical study.
|
||||
Your users are PIs (principal investigators) and research coordinators.
|
||||
|
||||
@@ -93,15 +117,15 @@ export class ChatOrchestrator {
|
||||
});
|
||||
|
||||
if (!response.toolCalls?.length || response.finishReason === 'stop') {
|
||||
const answer = response.content || '抱歉,我暂时无法回答这个问题。';
|
||||
const answer = stripToolCallXml(response.content || '') || '抱歉,我暂时无法回答这个问题。';
|
||||
this.saveConversation(userId, userMessage, answer, startTime);
|
||||
return answer;
|
||||
}
|
||||
|
||||
// Append assistant message with tool_calls
|
||||
// Append assistant message with tool_calls (strip leaked XML from content)
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: response.content,
|
||||
content: stripToolCallXml(response.content || ''),
|
||||
tool_calls: response.toolCalls,
|
||||
});
|
||||
|
||||
@@ -127,7 +151,7 @@ export class ChatOrchestrator {
|
||||
maxTokens: 1000,
|
||||
});
|
||||
|
||||
const answer = finalResponse.content || '抱歉,处理超时,请简化问题后重试。';
|
||||
const answer = stripToolCallXml(finalResponse.content || '') || '抱歉,处理超时,请简化问题后重试。';
|
||||
this.saveConversation(userId, userMessage, answer, startTime);
|
||||
return answer;
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -361,9 +361,19 @@ class QcReportServiceClass {
|
||||
const criticalIssues = seenCritical.size;
|
||||
const warningIssues = seenWarning.size;
|
||||
|
||||
// 计算通过率
|
||||
const passedRecords = latestQcLogs.filter(log =>
|
||||
log.status === 'PASS' || log.status === 'GREEN'
|
||||
// V3.2: 按 record 级别计算通过率(每个 record 取最严重状态)
|
||||
const statusPriority: Record<string, number> = { 'FAIL': 3, 'WARNING': 2, 'UNCERTAIN': 1, 'PASS': 0, 'GREEN': 0 };
|
||||
const recordWorstStatus = new Map<string, string>();
|
||||
for (const log of latestQcLogs) {
|
||||
const existing = recordWorstStatus.get(log.record_id);
|
||||
const currentPrio = statusPriority[log.status] ?? 0;
|
||||
const existingPrio = existing ? (statusPriority[existing] ?? 0) : -1;
|
||||
if (currentPrio > existingPrio) {
|
||||
recordWorstStatus.set(log.record_id, log.status);
|
||||
}
|
||||
}
|
||||
const passedRecords = [...recordWorstStatus.values()].filter(
|
||||
s => s === 'PASS' || s === 'GREEN'
|
||||
).length;
|
||||
const passRate = totalRecords > 0
|
||||
? Math.round((passedRecords / totalRecords) * 100 * 10) / 10
|
||||
|
||||
@@ -89,7 +89,7 @@ html, body { width: 100%; height: 100%; overflow: hidden;
|
||||
|
||||
function showGrantButton() {
|
||||
document.querySelector('#status .spinner').style.display = 'none';
|
||||
document.getElementById('msg').textContent = '需要授权以访问旧系统';
|
||||
document.getElementById('msg').textContent = '需要浏览器授权以继续访问';
|
||||
var btn = document.getElementById('btn');
|
||||
btn.style.display = 'inline-block';
|
||||
btn.onclick = function() {
|
||||
|
||||
Reference in New Issue
Block a user