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:
2026-03-01 15:27:05 +08:00
parent c3f7d54fdf
commit 0b29fe88b5
61 changed files with 6877 additions and 524 deletions

View File

@@ -111,19 +111,27 @@ import { userRoutes } from './modules/admin/routes/userRoutes.js';
import { statsRoutes, userOverviewRoute } from './modules/admin/routes/statsRoutes.js';
import { systemKbRoutes } from './modules/admin/system-kb/index.js';
import { iitProjectRoutes, iitQcRuleRoutes, iitUserMappingRoutes, iitBatchRoutes, iitQcCockpitRoutes, iitEqueryRoutes } from './modules/admin/iit-projects/index.js';
import { authenticate, requireRoles } from './common/auth/auth.middleware.js';
await fastify.register(tenantRoutes, { prefix: '/api/admin/tenants' });
await fastify.register(moduleRoutes, { prefix: '/api/admin/modules' });
await fastify.register(userRoutes, { prefix: '/api/admin/users' });
await fastify.register(statsRoutes, { prefix: '/api/admin/stats' });
await fastify.register(userOverviewRoute, { prefix: '/api/admin/users' });
await fastify.register(systemKbRoutes, { prefix: '/api/v1/admin/system-kb' });
await fastify.register(iitProjectRoutes, { prefix: '/api/v1/admin/iit-projects' });
await fastify.register(iitQcRuleRoutes, { prefix: '/api/v1/admin/iit-projects' });
await fastify.register(iitUserMappingRoutes, { prefix: '/api/v1/admin/iit-projects' });
await fastify.register(iitBatchRoutes, { prefix: '/api/v1/admin/iit-projects' }); // 一键全量质控/汇总
await fastify.register(iitQcCockpitRoutes, { prefix: '/api/v1/admin/iit-projects' }); // 质控驾驶舱
await fastify.register(iitEqueryRoutes, { prefix: '/api/v1/admin/iit-projects' }); // eQuery 闭环
logger.info('✅ 运营管理路由已注册: /api/admin/tenants, /api/admin/modules, /api/admin/users, /api/admin/stats, /api/v1/admin/system-kb, /api/v1/admin/iit-projects');
// IIT 项目管理路由 — 认证 + 角色守卫SUPER_ADMIN, PROMPT_ENGINEER, IIT_OPERATOR, PHARMA_ADMIN, HOSPITAL_ADMIN 可访问)
await fastify.register(async (scope) => {
scope.addHook('preHandler', authenticate);
scope.addHook('preHandler', requireRoles('SUPER_ADMIN', 'PROMPT_ENGINEER', 'IIT_OPERATOR', 'PHARMA_ADMIN', 'HOSPITAL_ADMIN'));
await scope.register(iitProjectRoutes);
await scope.register(iitQcRuleRoutes);
await scope.register(iitUserMappingRoutes);
await scope.register(iitBatchRoutes);
await scope.register(iitQcCockpitRoutes);
await scope.register(iitEqueryRoutes);
}, { prefix: '/api/v1/admin/iit-projects' });
logger.info('✅ 运营管理路由已注册: /api/admin/tenants, /api/admin/modules, /api/admin/users, /api/admin/stats, /api/v1/admin/system-kb, /api/v1/admin/iit-projects (authenticated)');
// ============================================
// 【临时】平台基础设施测试API

View File

@@ -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
});

View File

@@ -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);

View File

@@ -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 ====================
// 获取项目列表

View File

@@ -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(),
},
});

View File

@@ -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';
}

View File

@@ -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)' },

View File

@@ -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 }; // 只能查看本租户

View File

@@ -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 ====================

View File

@@ -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}`;
}
/**

View File

@@ -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,

View File

@@ -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,

View File

@@ -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');
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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() {