Implement IIT quality workflow hardening across eQuery deduplication, guard metadata validation, timeline/readability improvements, and chat evidence fallbacks, then synchronize release and development documentation for deployment handoff. Includes migration/scripts for open eQuery dedupe guards, orchestration/status semantics, report/tool readability fixes, and updated module status plus deployment checklist. Made-with: Cursor
1080 lines
33 KiB
TypeScript
1080 lines
33 KiB
TypeScript
/**
|
||
* QcReportService - 质控报告生成服务
|
||
*
|
||
* 功能:
|
||
* - 聚合质控统计数据
|
||
* - 生成 LLM 友好的 XML 报告
|
||
* - 缓存报告以提高查询效率
|
||
*
|
||
* 设计原则:
|
||
* - 报告驱动:预计算报告,LLM 只需阅读
|
||
* - 双模输出:人类可读 + LLM 友好格式
|
||
* - 智能缓存:报告有效期内直接返回缓存
|
||
*/
|
||
|
||
import { PrismaClient } from '@prisma/client';
|
||
import { logger } from '../../../common/logging/index.js';
|
||
|
||
const prisma = new PrismaClient();
|
||
|
||
// ============================================================
|
||
// 类型定义
|
||
// ============================================================
|
||
|
||
/**
|
||
* 报告摘要
|
||
*/
|
||
export interface ReportSummary {
|
||
totalRecords: number;
|
||
completedRecords: number;
|
||
criticalIssues: number;
|
||
warningIssues: number;
|
||
pendingQueries: number;
|
||
passRate: number;
|
||
lastQcTime: string | null;
|
||
healthScore?: number;
|
||
healthGrade?: string;
|
||
dimensionBreakdown?: DimensionPassRate[];
|
||
}
|
||
|
||
export interface DimensionPassRate {
|
||
code: string;
|
||
label: string;
|
||
passRate: number;
|
||
total: number;
|
||
failed: number;
|
||
}
|
||
|
||
/**
|
||
* 问题项 — V3.1 五级坐标
|
||
*/
|
||
export interface ReportIssue {
|
||
recordId: string;
|
||
eventId?: string;
|
||
formName?: string;
|
||
instanceId?: number;
|
||
fieldName?: string;
|
||
dimensionCode?: string;
|
||
ruleId: string;
|
||
ruleName: string;
|
||
severity: 'critical' | 'warning' | 'info';
|
||
message: string;
|
||
field?: string;
|
||
actualValue?: any;
|
||
expectedValue?: any;
|
||
evidence?: Record<string, any>;
|
||
semanticLabel?: string;
|
||
detectedAt: string;
|
||
}
|
||
|
||
/**
|
||
* 表单统计
|
||
*/
|
||
export interface FormStats {
|
||
formName: string;
|
||
formLabel: string;
|
||
totalChecks: number;
|
||
passed: number;
|
||
failed: number;
|
||
passRate: number;
|
||
}
|
||
|
||
/**
|
||
* V3.1: 事件级概览
|
||
*/
|
||
export interface EventOverview {
|
||
eventId: string;
|
||
eventLabel?: string;
|
||
status: string;
|
||
fieldsTotal: number;
|
||
fieldsPassed: number;
|
||
fieldsFailed: number;
|
||
fieldsWarning: number;
|
||
d1Issues: number;
|
||
d2Issues: number;
|
||
d3Issues: number;
|
||
d5Issues: number;
|
||
d6Issues: number;
|
||
d7Issues: number;
|
||
}
|
||
|
||
/**
|
||
* 质控报告
|
||
*/
|
||
export interface QcReport {
|
||
projectId: string;
|
||
reportType: 'daily' | 'weekly' | 'on_demand';
|
||
generatedAt: string;
|
||
expiresAt: string | null;
|
||
summary: ReportSummary;
|
||
criticalIssues: ReportIssue[];
|
||
warningIssues: ReportIssue[];
|
||
formStats: FormStats[];
|
||
eventOverview: EventOverview[];
|
||
topIssues: TopIssue[];
|
||
groupedIssues: GroupedIssues[];
|
||
llmFriendlyXml: string;
|
||
legacyXml?: string;
|
||
}
|
||
|
||
/**
|
||
* 报告选项
|
||
*/
|
||
export interface ReportOptions {
|
||
forceRefresh?: boolean; // 强制刷新,忽略缓存
|
||
reportType?: 'daily' | 'weekly' | 'on_demand';
|
||
expirationHours?: number; // 报告有效期(小时)
|
||
format?: 'xml' | 'llm-friendly'; // V2.1: 输出格式
|
||
}
|
||
|
||
/**
|
||
* V2.1: 按受试者分组的问题
|
||
*/
|
||
export interface GroupedIssues {
|
||
recordId: string;
|
||
issueCount: number;
|
||
issues: ReportIssue[];
|
||
}
|
||
|
||
/**
|
||
* V2.1: Top 问题统计
|
||
*/
|
||
export interface TopIssue {
|
||
ruleName: string;
|
||
ruleId: string;
|
||
count: number;
|
||
affectedRecords: string[];
|
||
}
|
||
|
||
// ============================================================
|
||
// QcReportService 实现
|
||
// ============================================================
|
||
|
||
class QcReportServiceClass {
|
||
/**
|
||
* 获取项目质控报告
|
||
*
|
||
* @param projectId 项目 ID
|
||
* @param options 报告选项
|
||
* @returns 质控报告
|
||
*/
|
||
async getReport(projectId: string, options?: ReportOptions): Promise<QcReport> {
|
||
const reportType = options?.reportType || 'on_demand';
|
||
const expirationHours = options?.expirationHours || 24;
|
||
|
||
// 1. 检查缓存
|
||
if (!options?.forceRefresh) {
|
||
const cached = await this.getCachedReport(projectId, reportType);
|
||
if (cached) {
|
||
logger.debug('[QcReportService] Returning cached report', {
|
||
projectId,
|
||
reportType,
|
||
generatedAt: cached.generatedAt,
|
||
});
|
||
return cached;
|
||
}
|
||
}
|
||
|
||
// 2. 生成新报告
|
||
logger.info('[QcReportService] Generating new report', {
|
||
projectId,
|
||
reportType,
|
||
});
|
||
|
||
const report = await this.generateReport(projectId, reportType, expirationHours);
|
||
|
||
// 3. 缓存报告
|
||
await this.cacheReport(report);
|
||
|
||
return report;
|
||
}
|
||
|
||
/**
|
||
* 获取缓存的报告
|
||
*/
|
||
private async getCachedReport(
|
||
projectId: string,
|
||
reportType: string
|
||
): Promise<QcReport | null> {
|
||
try {
|
||
const cached = await prisma.iitQcReport.findFirst({
|
||
where: {
|
||
projectId,
|
||
reportType,
|
||
OR: [
|
||
{ expiresAt: null },
|
||
{ expiresAt: { gt: new Date() } },
|
||
],
|
||
},
|
||
orderBy: { generatedAt: 'desc' },
|
||
});
|
||
|
||
if (!cached) {
|
||
return null;
|
||
}
|
||
|
||
// 若缓存早于最新质控时间,则视为过期,触发重算
|
||
const latestQcRows = await prisma.$queryRaw<Array<{ last_qc_at: Date | null }>>`
|
||
SELECT MAX(last_qc_at) AS last_qc_at
|
||
FROM iit_schema.qc_field_status
|
||
WHERE project_id = ${projectId}
|
||
`;
|
||
const latestQcAt = latestQcRows?.[0]?.last_qc_at;
|
||
if (latestQcAt && cached.generatedAt < latestQcAt) {
|
||
return null;
|
||
}
|
||
|
||
const issuesData = cached.issues as any || {};
|
||
|
||
return {
|
||
projectId: cached.projectId,
|
||
reportType: cached.reportType as 'daily' | 'weekly' | 'on_demand',
|
||
generatedAt: this.toBeijingTime(cached.generatedAt),
|
||
expiresAt: cached.expiresAt?.toISOString() || null,
|
||
summary: cached.summary as unknown as ReportSummary,
|
||
criticalIssues: (issuesData.critical || []) as ReportIssue[],
|
||
warningIssues: (issuesData.warning || []) as ReportIssue[],
|
||
formStats: (issuesData.formStats || []) as FormStats[],
|
||
eventOverview: (issuesData.eventOverview || []) as EventOverview[],
|
||
topIssues: (issuesData.topIssues || []) as TopIssue[],
|
||
groupedIssues: (issuesData.groupedIssues || []) as GroupedIssues[],
|
||
llmFriendlyXml: cached.llmReport,
|
||
legacyXml: issuesData.legacyXml,
|
||
};
|
||
} catch (error: any) {
|
||
logger.warn('[QcReportService] Failed to get cached report', {
|
||
projectId,
|
||
error: error.message,
|
||
});
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成新报告
|
||
*
|
||
* V2.1 优化:支持双格式输出
|
||
*/
|
||
private async generateReport(
|
||
projectId: string,
|
||
reportType: 'daily' | 'weekly' | 'on_demand',
|
||
expirationHours: number
|
||
): Promise<QcReport> {
|
||
const startTime = Date.now();
|
||
|
||
// 1. 获取项目信息
|
||
const project = await prisma.iitProject.findUnique({
|
||
where: { id: projectId },
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
fieldMappings: true,
|
||
},
|
||
});
|
||
|
||
if (!project) {
|
||
throw new Error(`项目不存在: ${projectId}`);
|
||
}
|
||
|
||
// 2. 聚合质控统计
|
||
const summary = await this.aggregateStats(projectId);
|
||
|
||
// 3. 获取问题列表
|
||
const { criticalIssues, warningIssues } = await this.getIssues(projectId);
|
||
|
||
// 4. 获取表单统计 + 事件概览
|
||
const [formStats, eventOverview] = await Promise.all([
|
||
this.getFormStats(projectId),
|
||
this.getEventOverview(projectId),
|
||
]);
|
||
|
||
// 5. 计算 Top Issues 和分组
|
||
const allIssues = [...criticalIssues, ...warningIssues];
|
||
const topIssues = this.calculateTopIssues(allIssues);
|
||
const groupedIssues = this.groupIssuesByRecord(criticalIssues);
|
||
|
||
// 6. 生成双格式 XML 报告
|
||
const llmFriendlyXml = this.buildLlmXmlReport(
|
||
projectId,
|
||
project.name || projectId,
|
||
summary,
|
||
criticalIssues,
|
||
warningIssues,
|
||
formStats,
|
||
eventOverview,
|
||
);
|
||
|
||
const legacyXml = this.buildLegacyXmlReport(
|
||
projectId,
|
||
project.name || projectId,
|
||
summary,
|
||
criticalIssues,
|
||
warningIssues,
|
||
formStats,
|
||
);
|
||
|
||
const generatedAt = new Date();
|
||
const expiresAt = new Date(generatedAt.getTime() + expirationHours * 60 * 60 * 1000);
|
||
|
||
const duration = Date.now() - startTime;
|
||
logger.info('[QcReportService] Report generated', {
|
||
projectId,
|
||
reportType,
|
||
duration: `${duration}ms`,
|
||
criticalCount: criticalIssues.length,
|
||
warningCount: warningIssues.length,
|
||
topIssuesCount: topIssues.length,
|
||
groupedRecordCount: groupedIssues.length,
|
||
});
|
||
|
||
return {
|
||
projectId,
|
||
reportType,
|
||
generatedAt: this.toBeijingTime(generatedAt),
|
||
expiresAt: expiresAt.toISOString(),
|
||
summary,
|
||
criticalIssues,
|
||
warningIssues,
|
||
formStats,
|
||
eventOverview,
|
||
topIssues,
|
||
groupedIssues,
|
||
llmFriendlyXml,
|
||
legacyXml,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* V3.1: 从 qc_project_stats + qc_field_status 聚合统计
|
||
*/
|
||
private async aggregateStats(projectId: string): Promise<ReportSummary> {
|
||
const [projectStats, recordSummaries, fieldCounts, pendingEqs, lastField] = await Promise.all([
|
||
prisma.iitQcProjectStats.findUnique({ where: { projectId } }),
|
||
prisma.iitRecordSummary.findMany({ where: { projectId } }),
|
||
prisma.$queryRaw<Array<{ severity: string; cnt: bigint }>>`
|
||
SELECT
|
||
CASE WHEN severity = 'critical' THEN 'critical' ELSE 'warning' END AS severity,
|
||
COUNT(*) AS cnt
|
||
FROM iit_schema.qc_field_status
|
||
WHERE project_id = ${projectId} AND status IN ('FAIL', 'WARNING')
|
||
GROUP BY 1
|
||
`,
|
||
prisma.iitEquery.count({ where: { projectId, status: { in: ['pending', 'reopened'] } } }),
|
||
prisma.$queryRaw<Array<{ last_qc_at: Date }>>`
|
||
SELECT last_qc_at FROM iit_schema.qc_field_status
|
||
WHERE project_id = ${projectId}
|
||
ORDER BY last_qc_at DESC LIMIT 1
|
||
`,
|
||
]);
|
||
|
||
const totalRecords = projectStats?.totalRecords ?? recordSummaries.length;
|
||
const completedRecords = recordSummaries.filter(
|
||
(r: any) => r.completionRate && r.completionRate >= 100,
|
||
).length;
|
||
|
||
let criticalIssues = 0;
|
||
let warningIssues = 0;
|
||
for (const fc of fieldCounts) {
|
||
const n = Number(fc.cnt);
|
||
if (fc.severity === 'critical') criticalIssues = n;
|
||
else warningIssues = n;
|
||
}
|
||
|
||
const passedRecords = projectStats?.passedRecords ?? 0;
|
||
const passRate = totalRecords > 0
|
||
? Math.round((passedRecords / totalRecords) * 1000) / 10
|
||
: 0;
|
||
|
||
const DIMENSION_LABELS: Record<string, string> = {
|
||
D1: '数据一致性', D2: '数据完整性', D3: '数据准确性',
|
||
D5: '时效性', D6: '方案依从', D7: '安全性',
|
||
};
|
||
|
||
const dimensionBreakdown: DimensionPassRate[] = [];
|
||
if (projectStats) {
|
||
const ps = projectStats as any;
|
||
for (const [code, label] of Object.entries(DIMENSION_LABELS)) {
|
||
const rateField = `${code.toLowerCase()}PassRate`;
|
||
const rate = (ps[rateField] as number) ?? 100;
|
||
dimensionBreakdown.push({ code, label, passRate: rate, total: 0, failed: 0 });
|
||
}
|
||
}
|
||
|
||
return {
|
||
totalRecords,
|
||
completedRecords,
|
||
criticalIssues,
|
||
warningIssues,
|
||
pendingQueries: pendingEqs,
|
||
passRate,
|
||
lastQcTime: (lastField as any)?.[0]?.last_qc_at
|
||
? this.toBeijingTime(new Date((lastField as any)[0].last_qc_at))
|
||
: null,
|
||
healthScore: ((projectStats as any)?.healthScore as number) ?? undefined,
|
||
healthGrade: ((projectStats as any)?.healthGrade as string) ?? undefined,
|
||
dimensionBreakdown,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* V3.1: 从 qc_field_status 获取问题列表(五级坐标)
|
||
*/
|
||
private async getIssues(projectId: string): Promise<{
|
||
criticalIssues: ReportIssue[];
|
||
warningIssues: ReportIssue[];
|
||
}> {
|
||
const fieldRows = await prisma.$queryRaw<Array<{
|
||
record_id: string; event_id: string; form_name: string; instance_id: number;
|
||
field_name: string; status: string; rule_id: string | null; rule_name: string | null;
|
||
rule_category: string | null; severity: string | null; message: string | null;
|
||
actual_value: string | null; expected_value: string | null; last_qc_at: Date | null;
|
||
}>>`
|
||
SELECT record_id, event_id, form_name, instance_id, field_name, status,
|
||
rule_id, rule_name, rule_category, severity, message,
|
||
actual_value, expected_value, last_qc_at
|
||
FROM iit_schema.qc_field_status
|
||
WHERE project_id = ${projectId} AND status IN ('FAIL', 'WARNING')
|
||
ORDER BY last_qc_at DESC
|
||
`;
|
||
|
||
const semanticMap = await this.getSemanticLabelMap(projectId);
|
||
|
||
const criticalIssues: ReportIssue[] = [];
|
||
const warningIssues: ReportIssue[] = [];
|
||
|
||
for (const row of fieldRows) {
|
||
const reportIssue: ReportIssue = {
|
||
recordId: row.record_id,
|
||
eventId: row.event_id,
|
||
formName: row.form_name,
|
||
instanceId: row.instance_id,
|
||
fieldName: row.field_name,
|
||
dimensionCode: row.rule_category || undefined,
|
||
ruleId: row.rule_id || 'unknown',
|
||
ruleName: row.rule_name || 'Unknown',
|
||
severity: this.normalizeSeverity(row.severity || row.status),
|
||
message: row.message || '',
|
||
field: row.field_name,
|
||
actualValue: row.actual_value,
|
||
expectedValue: row.expected_value,
|
||
semanticLabel: semanticMap.get(row.field_name),
|
||
detectedAt: row.last_qc_at?.toISOString() || new Date().toISOString(),
|
||
};
|
||
|
||
if (reportIssue.severity === 'critical') {
|
||
criticalIssues.push(reportIssue);
|
||
} else {
|
||
warningIssues.push(reportIssue);
|
||
}
|
||
}
|
||
|
||
return { criticalIssues, warningIssues };
|
||
}
|
||
|
||
/**
|
||
* V3.1: 事件级概览(从 qc_event_status)
|
||
*/
|
||
private async getEventOverview(projectId: string): Promise<EventOverview[]> {
|
||
const rows = await prisma.$queryRaw<Array<{
|
||
event_id: string;
|
||
event_label: string | null;
|
||
status: string;
|
||
fields_total: number;
|
||
fields_passed: number;
|
||
fields_failed: number;
|
||
fields_warning: number;
|
||
d1_issues: number;
|
||
d2_issues: number;
|
||
d3_issues: number;
|
||
d5_issues: number;
|
||
d6_issues: number;
|
||
d7_issues: number;
|
||
}>>`
|
||
SELECT
|
||
event_id,
|
||
MAX(event_label) AS event_label,
|
||
CASE
|
||
WHEN SUM(CASE WHEN status = 'FAIL' THEN 1 ELSE 0 END) > 0 THEN 'FAIL'
|
||
WHEN SUM(CASE WHEN status = 'WARNING' THEN 1 ELSE 0 END) > 0 THEN 'WARNING'
|
||
ELSE 'PASS'
|
||
END AS status,
|
||
COALESCE(SUM(fields_total), 0)::int AS fields_total,
|
||
COALESCE(SUM(fields_passed), 0)::int AS fields_passed,
|
||
COALESCE(SUM(fields_failed), 0)::int AS fields_failed,
|
||
COALESCE(SUM(fields_warning), 0)::int AS fields_warning,
|
||
COALESCE(SUM(d1_issues), 0)::int AS d1_issues,
|
||
COALESCE(SUM(d2_issues), 0)::int AS d2_issues,
|
||
COALESCE(SUM(d3_issues), 0)::int AS d3_issues,
|
||
COALESCE(SUM(d5_issues), 0)::int AS d5_issues,
|
||
COALESCE(SUM(d6_issues), 0)::int AS d6_issues,
|
||
COALESCE(SUM(d7_issues), 0)::int AS d7_issues
|
||
FROM iit_schema.qc_event_status
|
||
WHERE project_id = ${projectId}
|
||
GROUP BY event_id
|
||
ORDER BY event_id
|
||
`;
|
||
|
||
return rows.map(r => ({
|
||
eventId: r.event_id,
|
||
eventLabel: r.event_label || undefined,
|
||
status: r.status,
|
||
fieldsTotal: r.fields_total,
|
||
fieldsPassed: r.fields_passed,
|
||
fieldsFailed: r.fields_failed,
|
||
fieldsWarning: r.fields_warning,
|
||
d1Issues: r.d1_issues,
|
||
d2Issues: r.d2_issues,
|
||
d3Issues: r.d3_issues,
|
||
d5Issues: r.d5_issues,
|
||
d6Issues: r.d6_issues,
|
||
d7Issues: r.d7_issues,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* V3.1: 从 IitFieldMapping 获取语义标签映射
|
||
*/
|
||
private async getSemanticLabelMap(projectId: string): Promise<Map<string, string>> {
|
||
const map = new Map<string, string>();
|
||
try {
|
||
const mappings = await prisma.$queryRaw<Array<{
|
||
actual_name: string; semantic_label: string | null;
|
||
}>>`
|
||
SELECT actual_name, semantic_label FROM iit_schema.field_mapping
|
||
WHERE project_id = ${projectId} AND semantic_label IS NOT NULL
|
||
`;
|
||
for (const m of mappings) {
|
||
if (m.semantic_label) map.set(m.actual_name, m.semantic_label);
|
||
}
|
||
} catch {
|
||
// non-fatal
|
||
}
|
||
return map;
|
||
}
|
||
|
||
/**
|
||
* V2.1: 构建自包含的 LLM 友好消息
|
||
*/
|
||
private buildSelfContainedMessage(issue: any): string {
|
||
const ruleName = issue.ruleName || issue.message || 'Unknown';
|
||
const actualValue = issue.actualValue;
|
||
const expectedValue = issue.expectedValue || this.extractExpectedFromMessage(issue.message);
|
||
|
||
// 如果已经有自包含格式,直接返回
|
||
if (issue.llmMessage) {
|
||
return issue.llmMessage;
|
||
}
|
||
|
||
// 构建自包含格式
|
||
const actualDisplay = actualValue !== undefined && actualValue !== null && actualValue !== ''
|
||
? `**${actualValue}**`
|
||
: '**空**';
|
||
|
||
if (expectedValue) {
|
||
return `**${ruleName}**: 当前值 ${actualDisplay} (标准: ${expectedValue})`;
|
||
}
|
||
|
||
return `**${ruleName}**: 当前值 ${actualDisplay}`;
|
||
}
|
||
|
||
/**
|
||
* V2.1: 从原始消息中提取期望值
|
||
* 例如:"年龄不在 25-35 岁范围内" -> "25-35 岁"
|
||
*/
|
||
private extractExpectedFromMessage(message: string): string | undefined {
|
||
if (!message) return undefined;
|
||
|
||
// 尝试提取数字范围:如 "25-35"
|
||
const rangeMatch = message.match(/(\d+)\s*[-~至到]\s*(\d+)/);
|
||
if (rangeMatch) {
|
||
return `${rangeMatch[1]}-${rangeMatch[2]}`;
|
||
}
|
||
|
||
// 尝试提取日期范围
|
||
const dateRangeMatch = message.match(/(\d{4}-\d{2}-\d{2})\s*至\s*(\d{4}-\d{2}-\d{2})/);
|
||
if (dateRangeMatch) {
|
||
return `${dateRangeMatch[1]} 至 ${dateRangeMatch[2]}`;
|
||
}
|
||
|
||
return undefined;
|
||
}
|
||
|
||
/**
|
||
* 标准化严重程度
|
||
*/
|
||
private normalizeSeverity(severity: string): 'critical' | 'warning' | 'info' {
|
||
const lower = (severity || '').toLowerCase();
|
||
if (lower === 'critical' || lower === 'red' || lower === 'error') {
|
||
return 'critical';
|
||
} else if (lower === 'warning' || lower === 'yellow') {
|
||
return 'warning';
|
||
}
|
||
return 'info';
|
||
}
|
||
|
||
/**
|
||
* V3.1: 从 qc_field_status 获取表单统计
|
||
*/
|
||
private async getFormStats(projectId: string): Promise<FormStats[]> {
|
||
const project = await prisma.iitProject.findUnique({
|
||
where: { id: projectId },
|
||
select: { fieldMappings: true },
|
||
});
|
||
const formLabels: Record<string, string> =
|
||
((project?.fieldMappings as any)?.formLabels) || {};
|
||
|
||
const formStatsRaw = await prisma.$queryRaw<Array<{
|
||
form_name: string;
|
||
total: bigint;
|
||
passed: bigint;
|
||
failed: bigint;
|
||
}>>`
|
||
SELECT
|
||
form_name,
|
||
COUNT(*) AS total,
|
||
COUNT(*) FILTER (WHERE status = 'PASS') AS passed,
|
||
COUNT(*) FILTER (WHERE status IN ('FAIL', 'WARNING')) AS failed
|
||
FROM iit_schema.qc_field_status
|
||
WHERE project_id = ${projectId}
|
||
GROUP BY form_name
|
||
ORDER BY form_name
|
||
`;
|
||
|
||
return formStatsRaw.map(row => {
|
||
const total = Number(row.total);
|
||
const passed = Number(row.passed);
|
||
const failed = Number(row.failed);
|
||
return {
|
||
formName: row.form_name,
|
||
formLabel: formLabels[row.form_name] || this.formatFormName(row.form_name),
|
||
totalChecks: total,
|
||
passed,
|
||
failed,
|
||
passRate: total > 0 ? Math.round((passed / total) * 1000) / 10 : 0,
|
||
};
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 格式化表单名称
|
||
*/
|
||
private formatFormName(formName: string): string {
|
||
return formName
|
||
.replace(/_/g, ' ')
|
||
.replace(/\b\w/g, c => c.toUpperCase());
|
||
}
|
||
|
||
/**
|
||
* V2.1: 按受试者分组问题
|
||
*/
|
||
private groupIssuesByRecord(issues: ReportIssue[]): GroupedIssues[] {
|
||
const grouped = new Map<string, ReportIssue[]>();
|
||
|
||
for (const issue of issues) {
|
||
const existing = grouped.get(issue.recordId) || [];
|
||
existing.push(issue);
|
||
grouped.set(issue.recordId, existing);
|
||
}
|
||
|
||
return Array.from(grouped.entries())
|
||
.map(([recordId, issues]) => ({
|
||
recordId,
|
||
issueCount: issues.length,
|
||
issues,
|
||
}))
|
||
.sort((a, b) => a.recordId.localeCompare(b.recordId, undefined, { numeric: true }));
|
||
}
|
||
|
||
/**
|
||
* V2.1: 计算 Top Issues 统计
|
||
*/
|
||
private calculateTopIssues(issues: ReportIssue[], limit: number = 5): TopIssue[] {
|
||
const ruleStats = new Map<string, { ruleName: string; ruleId: string; records: Set<string> }>();
|
||
|
||
for (const issue of issues) {
|
||
const key = issue.ruleId || issue.ruleName;
|
||
const existing = ruleStats.get(key) || {
|
||
ruleName: issue.ruleName,
|
||
ruleId: issue.ruleId,
|
||
records: new Set<string>(),
|
||
};
|
||
existing.records.add(issue.recordId);
|
||
ruleStats.set(key, existing);
|
||
}
|
||
|
||
return Array.from(ruleStats.values())
|
||
.map(stat => ({
|
||
ruleName: stat.ruleName,
|
||
ruleId: stat.ruleId,
|
||
count: stat.records.size,
|
||
affectedRecords: Array.from(stat.records),
|
||
}))
|
||
.sort((a, b) => b.count - a.count)
|
||
.slice(0, limit);
|
||
}
|
||
|
||
/**
|
||
* 构建 LLM 友好的 XML 报告
|
||
*
|
||
* V2.1 优化:
|
||
* - 按受试者分组
|
||
* - 添加 Top Issues 统计
|
||
* - 自包含的 message 格式
|
||
*/
|
||
private buildLlmXmlReport(
|
||
projectId: string,
|
||
projectName: string,
|
||
summary: ReportSummary,
|
||
criticalIssues: ReportIssue[],
|
||
warningIssues: ReportIssue[],
|
||
formStats: FormStats[],
|
||
eventOverview?: EventOverview[],
|
||
): string {
|
||
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
|
||
|
||
const allIssues = [...criticalIssues, ...warningIssues];
|
||
const topIssues = this.calculateTopIssues(allIssues);
|
||
const groupedCritical = this.groupIssuesByRecord(criticalIssues);
|
||
const failedRecordCount = groupedCritical.length;
|
||
|
||
const hs = summary.healthScore != null ? ` health_score="${summary.healthScore}" health_grade="${summary.healthGrade}"` : '';
|
||
|
||
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<qc_context project_id="${projectId}" project_name="${this.escapeXml(projectName)}" generated="${now}"${hs}>
|
||
|
||
<!-- 1. 宏观统计 -->
|
||
<summary>
|
||
- 健康度: ${summary.healthScore ?? 'N/A'} (${summary.healthGrade ?? '-'})
|
||
- 状态: ${failedRecordCount}/${summary.totalRecords} 记录存在严重违规
|
||
- 通过率: ${summary.passRate}%
|
||
- 严重问题: ${summary.criticalIssues} | 警告: ${summary.warningIssues}
|
||
${topIssues.length > 0 ? ` - Top ${Math.min(3, topIssues.length)} 问题:\n${topIssues.slice(0, 3).map((t, i) => ` ${i + 1}. ${t.ruleName} (${t.count}人)`).join('\n')}` : ''}
|
||
</summary>
|
||
|
||
`;
|
||
|
||
// dimension_summary
|
||
if (summary.dimensionBreakdown && summary.dimensionBreakdown.length > 0) {
|
||
xml += ` <!-- 2. 维度通过率 -->\n`;
|
||
xml += ` <dimension_summary>\n`;
|
||
for (const d of summary.dimensionBreakdown) {
|
||
xml += ` <dimension code="${d.code}" label="${this.escapeXml(d.label)}" pass_rate="${d.passRate.toFixed(1)}%" />\n`;
|
||
}
|
||
xml += ` </dimension_summary>\n\n`;
|
||
}
|
||
|
||
// event_overview
|
||
if (eventOverview && eventOverview.length > 0) {
|
||
xml += ` <!-- 3. 事件概览 -->\n`;
|
||
xml += ` <event_overview count="${eventOverview.length}">\n`;
|
||
for (const ev of eventOverview) {
|
||
xml += ` <event id="${this.escapeXml(ev.eventId)}" status="${ev.status}" fields_total="${ev.fieldsTotal}" fields_failed="${ev.fieldsFailed}" />\n`;
|
||
}
|
||
xml += ` </event_overview>\n\n`;
|
||
}
|
||
|
||
// critical issues (grouped by record, five-level coordinates)
|
||
if (groupedCritical.length > 0) {
|
||
xml += ` <!-- 4. 严重问题详情 -->\n`;
|
||
xml += ` <critical_issues record_count="${groupedCritical.length}" issue_count="${criticalIssues.length}">\n\n`;
|
||
|
||
for (const group of groupedCritical) {
|
||
xml += ` <record id="${group.recordId}">\n`;
|
||
for (let i = 0; i < group.issues.length; i++) {
|
||
const issue = group.issues[i];
|
||
xml += this.buildV31IssueLine(issue, i + 1, ' ');
|
||
}
|
||
xml += ` </record>\n\n`;
|
||
}
|
||
xml += ` </critical_issues>\n\n`;
|
||
} else {
|
||
xml += ` <critical_issues record_count="0" issue_count="0" />\n\n`;
|
||
}
|
||
|
||
// warning issues
|
||
const groupedWarning = this.groupIssuesByRecord(warningIssues);
|
||
if (groupedWarning.length > 0) {
|
||
xml += ` <!-- 5. 警告问题 -->\n`;
|
||
xml += ` <warning_issues record_count="${groupedWarning.length}" issue_count="${warningIssues.length}">\n`;
|
||
for (const group of groupedWarning) {
|
||
xml += ` <record id="${group.recordId}">\n`;
|
||
for (let i = 0; i < group.issues.length; i++) {
|
||
xml += this.buildV31IssueLine(group.issues[i], i + 1, ' ');
|
||
}
|
||
xml += ` </record>\n`;
|
||
}
|
||
xml += ` </warning_issues>\n\n`;
|
||
}
|
||
|
||
xml += `</qc_context>`;
|
||
return xml;
|
||
}
|
||
|
||
/**
|
||
* V3.1: 五级坐标 + 维度标注的问题行
|
||
*/
|
||
private buildV31IssueLine(issue: ReportIssue, index: number, indent: string): string {
|
||
const dim = issue.dimensionCode ? `[${issue.dimensionCode}] ` : '';
|
||
const label = issue.semanticLabel || issue.fieldName || issue.field || '';
|
||
const loc = [issue.eventId, issue.formName, issue.fieldName].filter(Boolean).join(' > ');
|
||
const actual = issue.actualValue != null ? ` 当前值=${issue.actualValue}` : '';
|
||
const expected = issue.expectedValue ? ` (标准: ${issue.expectedValue})` : '';
|
||
|
||
return `${indent}${index}. ${dim}${loc ? `[${loc}] ` : ''}${label}: ${issue.message}${actual}${expected}\n`;
|
||
}
|
||
|
||
/**
|
||
* V2.1: 构建 LLM 友好的单行问题描述
|
||
*
|
||
* 格式: [规则ID] **问题类型**: 当前 **值** (标准: xxx)
|
||
*/
|
||
private buildLlmIssueLine(issue: ReportIssue, index: number): string {
|
||
const ruleId = issue.ruleId !== 'unknown' ? `[${issue.ruleId}]` : '';
|
||
|
||
// 尝试使用 llmMessage(如果 HardRuleEngine 已经生成)
|
||
if (issue.message && issue.message.includes('**')) {
|
||
// 已经是自包含格式
|
||
return `${index}. ${ruleId} ${issue.message}`;
|
||
}
|
||
|
||
// 回退:手动构建
|
||
const actualDisplay = issue.actualValue !== undefined && issue.actualValue !== null && issue.actualValue !== ''
|
||
? `**${issue.actualValue}**`
|
||
: '**空**';
|
||
|
||
const expectedDisplay = issue.expectedValue
|
||
? ` (标准: ${issue.expectedValue})`
|
||
: '';
|
||
|
||
return `${index}. ${ruleId} **${issue.ruleName}**: 当前值 ${actualDisplay}${expectedDisplay}`;
|
||
}
|
||
|
||
/**
|
||
* 构建原始 XML 报告(兼容旧格式)
|
||
*/
|
||
private buildLegacyXmlReport(
|
||
projectId: string,
|
||
projectName: string,
|
||
summary: ReportSummary,
|
||
criticalIssues: ReportIssue[],
|
||
warningIssues: ReportIssue[],
|
||
formStats: FormStats[]
|
||
): string {
|
||
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
|
||
|
||
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<qc_report project_id="${projectId}" project_name="${this.escapeXml(projectName)}" generated="${now}">
|
||
|
||
<summary>
|
||
<total_records>${summary.totalRecords}</total_records>
|
||
<completed_records>${summary.completedRecords}</completed_records>
|
||
<critical_issues>${summary.criticalIssues}</critical_issues>
|
||
<warning_issues>${summary.warningIssues}</warning_issues>
|
||
<pending_queries>${summary.pendingQueries}</pending_queries>
|
||
<pass_rate>${summary.passRate}%</pass_rate>
|
||
<last_qc_time>${summary.lastQcTime || 'N/A'}</last_qc_time>
|
||
</summary>
|
||
|
||
`;
|
||
|
||
// V3.1: 严重问题列表(显示所有)
|
||
if (criticalIssues.length > 0) {
|
||
xml += ` <critical_issues count="${criticalIssues.length}">\n`;
|
||
for (const issue of criticalIssues) {
|
||
xml += this.buildIssueXml(issue, ' ');
|
||
}
|
||
xml += ` </critical_issues>\n\n`;
|
||
} else {
|
||
xml += ` <critical_issues count="0" />\n\n`;
|
||
}
|
||
|
||
// V3.1: 警告问题列表(显示所有)
|
||
if (warningIssues.length > 0) {
|
||
xml += ` <warning_issues count="${warningIssues.length}">\n`;
|
||
for (const issue of warningIssues) {
|
||
xml += this.buildIssueXml(issue, ' ');
|
||
}
|
||
xml += ` </warning_issues>\n\n`;
|
||
} else {
|
||
xml += ` <warning_issues count="0" />\n\n`;
|
||
}
|
||
|
||
// 表单统计
|
||
if (formStats.length > 0) {
|
||
xml += ` <form_statistics>\n`;
|
||
for (const form of formStats) {
|
||
xml += ` <form name="${this.escapeXml(form.formName)}" label="${this.escapeXml(form.formLabel)}">\n`;
|
||
xml += ` <total_checks>${form.totalChecks}</total_checks>\n`;
|
||
xml += ` <passed>${form.passed}</passed>\n`;
|
||
xml += ` <failed>${form.failed}</failed>\n`;
|
||
xml += ` <pass_rate>${form.passRate}%</pass_rate>\n`;
|
||
xml += ` </form>\n`;
|
||
}
|
||
xml += ` </form_statistics>\n\n`;
|
||
}
|
||
|
||
xml += `</qc_report>`;
|
||
|
||
return xml;
|
||
}
|
||
|
||
/**
|
||
* 构建单个问题的 XML
|
||
*/
|
||
private buildIssueXml(issue: ReportIssue, indent: string): string {
|
||
let xml = `${indent}<issue record="${issue.recordId}" rule="${this.escapeXml(issue.ruleId)}" severity="${issue.severity}">\n`;
|
||
xml += `${indent} <rule_name>${this.escapeXml(issue.ruleName)}</rule_name>\n`;
|
||
xml += `${indent} <message>${this.escapeXml(issue.message)}</message>\n`;
|
||
|
||
if (issue.field) {
|
||
xml += `${indent} <field>${this.escapeXml(String(issue.field))}</field>\n`;
|
||
}
|
||
if (issue.actualValue !== undefined) {
|
||
xml += `${indent} <actual_value>${this.escapeXml(String(issue.actualValue))}</actual_value>\n`;
|
||
}
|
||
if (issue.expectedValue !== undefined) {
|
||
xml += `${indent} <expected_value>${this.escapeXml(String(issue.expectedValue))}</expected_value>\n`;
|
||
}
|
||
if (issue.evidence && Object.keys(issue.evidence).length > 0) {
|
||
xml += `${indent} <evidence>\n`;
|
||
for (const [key, value] of Object.entries(issue.evidence)) {
|
||
xml += `${indent} <${this.escapeXml(key)}>${this.escapeXml(String(value))}</${this.escapeXml(key)}>\n`;
|
||
}
|
||
xml += `${indent} </evidence>\n`;
|
||
}
|
||
|
||
xml += `${indent} <detected_at>${issue.detectedAt}</detected_at>\n`;
|
||
xml += `${indent}</issue>\n`;
|
||
|
||
return xml;
|
||
}
|
||
|
||
/**
|
||
* UTC Date → 北京时间字符串(yyyy/M/d HH:mm:ss 格式,可被 new Date() 解析回来)
|
||
*/
|
||
private toBeijingTime(date: Date): string {
|
||
return date.toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai', hour12: false }).replace(' ', 'T') + '+08:00';
|
||
}
|
||
|
||
/**
|
||
* 从 toBeijingTime 输出反解回 Date 对象
|
||
*/
|
||
private parseBeijingTime(str: string): Date {
|
||
return new Date(str);
|
||
}
|
||
|
||
/**
|
||
* XML 转义
|
||
*/
|
||
private escapeXml(str: string): string {
|
||
return str
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
/**
|
||
* 缓存报告
|
||
*/
|
||
private async cacheReport(report: QcReport): Promise<void> {
|
||
try {
|
||
await prisma.iitQcReport.create({
|
||
data: {
|
||
projectId: report.projectId,
|
||
reportType: report.reportType,
|
||
summary: report.summary as any,
|
||
issues: {
|
||
critical: report.criticalIssues,
|
||
warning: report.warningIssues,
|
||
formStats: report.formStats,
|
||
eventOverview: report.eventOverview,
|
||
topIssues: report.topIssues,
|
||
groupedIssues: report.groupedIssues,
|
||
legacyXml: report.legacyXml,
|
||
} as any,
|
||
llmReport: report.llmFriendlyXml,
|
||
generatedAt: this.parseBeijingTime(report.generatedAt),
|
||
expiresAt: report.expiresAt ? new Date(report.expiresAt) : null,
|
||
},
|
||
});
|
||
|
||
logger.debug('[QcReportService] Report cached', {
|
||
projectId: report.projectId,
|
||
reportType: report.reportType,
|
||
});
|
||
} catch (error: any) {
|
||
logger.warn('[QcReportService] Failed to cache report', {
|
||
projectId: report.projectId,
|
||
error: error.message,
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取 LLM 友好的报告(用于问答)
|
||
*
|
||
* @param projectId 项目 ID
|
||
* @param format 格式:'llm-friendly' (默认) 或 'xml' (兼容格式)
|
||
* @returns XML 报告
|
||
*/
|
||
async getLlmReport(projectId: string, format: 'llm-friendly' | 'xml' = 'llm-friendly'): Promise<string> {
|
||
const report = await this.getReport(projectId);
|
||
|
||
if (format === 'xml') {
|
||
return report.legacyXml || report.llmFriendlyXml;
|
||
}
|
||
|
||
return report.llmFriendlyXml;
|
||
}
|
||
|
||
/**
|
||
* V2.1: 获取 Top Issues 统计
|
||
*/
|
||
async getTopIssues(projectId: string, limit: number = 5): Promise<TopIssue[]> {
|
||
const report = await this.getReport(projectId);
|
||
return report.topIssues.slice(0, limit);
|
||
}
|
||
|
||
/**
|
||
* V2.1: 获取按受试者分组的问题
|
||
*/
|
||
async getGroupedIssues(projectId: string): Promise<GroupedIssues[]> {
|
||
const report = await this.getReport(projectId);
|
||
return report.groupedIssues;
|
||
}
|
||
|
||
/**
|
||
* 强制刷新报告
|
||
*
|
||
* @param projectId 项目 ID
|
||
* @returns 新生成的报告
|
||
*/
|
||
async refreshReport(projectId: string): Promise<QcReport> {
|
||
return this.getReport(projectId, { forceRefresh: true });
|
||
}
|
||
|
||
/**
|
||
* 清理过期报告
|
||
*/
|
||
async cleanupExpiredReports(): Promise<number> {
|
||
const result = await prisma.iitQcReport.deleteMany({
|
||
where: {
|
||
expiresAt: {
|
||
lt: new Date(),
|
||
},
|
||
},
|
||
});
|
||
|
||
logger.info('[QcReportService] Cleaned up expired reports', {
|
||
count: result.count,
|
||
});
|
||
|
||
return result.count;
|
||
}
|
||
}
|
||
|
||
// 单例导出
|
||
export const QcReportService = new QcReportServiceClass();
|