Files
AIclinicalresearch/backend/src/modules/iit-manager/services/QcReportService.ts
HaHafeng a666649fd4 feat(iit): harden QC pipeline consistency and release artifacts
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
2026-03-08 21:54:35 +08:00

1080 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* 缓存报告
*/
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();