feat(iit): Implement event-level QC architecture V3.1 with dynamic rule filtering, report deduplication and AI intent enhancement

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-08 21:22:11 +08:00
parent 45c7b32dbb
commit 7a299e8562
51 changed files with 10638 additions and 184 deletions

View File

@@ -0,0 +1,979 @@
/**
* 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;
}
/**
* 问题项
*/
export interface ReportIssue {
recordId: string;
ruleId: string;
ruleName: string;
severity: 'critical' | 'warning' | 'info';
message: string;
field?: string;
actualValue?: any;
expectedValue?: any;
evidence?: Record<string, any>;
detectedAt: string;
}
/**
* 表单统计
*/
export interface FormStats {
formName: string;
formLabel: string;
totalChecks: number;
passed: number;
failed: number;
passRate: number;
}
/**
* 质控报告
*/
export interface QcReport {
projectId: string;
reportType: 'daily' | 'weekly' | 'on_demand';
generatedAt: string;
expiresAt: string | null;
summary: ReportSummary;
criticalIssues: ReportIssue[];
warningIssues: ReportIssue[];
formStats: FormStats[];
topIssues: TopIssue[]; // V2.1: Top 问题统计
groupedIssues: GroupedIssues[]; // V2.1: 按受试者分组
llmFriendlyXml: string; // V2.1: LLM 友好格式
legacyXml?: string; // V2.1: 兼容旧格式
}
/**
* 报告选项
*/
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 issuesData = cached.issues as any || {};
return {
projectId: cached.projectId,
reportType: cached.reportType as 'daily' | 'weekly' | 'on_demand',
generatedAt: cached.generatedAt.toISOString(),
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[],
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 = await this.getFormStats(projectId);
// 5. V2.1: 计算 Top Issues 和分组
const allIssues = [...criticalIssues, ...warningIssues];
const topIssues = this.calculateTopIssues(allIssues);
const groupedIssues = this.groupIssuesByRecord(criticalIssues);
// 6. V2.1: 生成双格式 XML 报告
const llmFriendlyXml = this.buildLlmXmlReport(
projectId,
project.name || projectId,
summary,
criticalIssues,
warningIssues,
formStats
);
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: generatedAt.toISOString(),
expiresAt: expiresAt.toISOString(),
summary,
criticalIssues,
warningIssues,
formStats,
topIssues,
groupedIssues,
llmFriendlyXml,
legacyXml,
};
}
/**
* 聚合质控统计
*
* V3.1: 修复记录数统计和 issues 格式兼容性
*/
private async aggregateStats(projectId: string): Promise<ReportSummary> {
// 获取记录汇总(用于 completedRecords 统计)
const recordSummaries = await prisma.iitRecordSummary.findMany({
where: { projectId },
});
const completedRecords = recordSummaries.filter(r =>
r.completionRate && (r.completionRate as number) >= 100
).length;
// V3.1: 获取每个 record+event 的最新质控日志(避免重复)
const latestQcLogs = await prisma.$queryRaw<Array<{
record_id: string;
event_id: string | null;
form_name: string | null;
status: string;
issues: any;
created_at: Date;
}>>`
SELECT DISTINCT ON (record_id, COALESCE(event_id, ''))
record_id, event_id, form_name, status, issues, created_at
FROM iit_schema.qc_logs
WHERE project_id = ${projectId}
ORDER BY record_id, COALESCE(event_id, ''), created_at DESC
`;
// V3.1: 从质控日志获取独立 record_id 数量
const uniqueRecordIds = new Set(latestQcLogs.map(log => log.record_id));
const totalRecords = uniqueRecordIds.size;
// V3.1: 统计问题数量(按 recordId + ruleId 去重)
const seenCritical = new Set<string>();
const seenWarning = new Set<string>();
let pendingQueries = 0;
for (const log of latestQcLogs) {
// V3.1: 兼容两种 issues 格式
const rawIssues = log.issues as any;
let issues: any[] = [];
if (Array.isArray(rawIssues)) {
issues = rawIssues;
} else if (rawIssues && Array.isArray(rawIssues.items)) {
issues = rawIssues.items;
}
for (const issue of issues) {
const ruleId = issue.ruleId || issue.ruleName || 'unknown';
const key = `${log.record_id}:${ruleId}`;
const severity = issue.severity || issue.level;
if (severity === 'critical' || severity === 'RED' || severity === 'error') {
seenCritical.add(key);
} else if (severity === 'warning' || severity === 'YELLOW') {
seenWarning.add(key);
}
}
if (log.status === 'UNCERTAIN' || log.status === 'PENDING') {
pendingQueries++;
}
}
const criticalIssues = seenCritical.size;
const warningIssues = seenWarning.size;
// 计算通过率
const passedRecords = latestQcLogs.filter(log =>
log.status === 'PASS' || log.status === 'GREEN'
).length;
const passRate = totalRecords > 0
? Math.round((passedRecords / totalRecords) * 100 * 10) / 10
: 0;
// 获取最后质控时间
const lastQcLog = await prisma.iitQcLog.findFirst({
where: { projectId },
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
});
return {
totalRecords,
completedRecords,
criticalIssues,
warningIssues,
pendingQueries,
passRate,
lastQcTime: lastQcLog?.createdAt?.toISOString() || null,
};
}
/**
* 获取问题列表
*/
private async getIssues(projectId: string): Promise<{
criticalIssues: ReportIssue[];
warningIssues: ReportIssue[];
}> {
// V3.1: 获取每个 record+event 的最新质控日志
const latestQcLogs = await prisma.$queryRaw<Array<{
record_id: string;
event_id: string | null;
form_name: string | null;
status: string;
issues: any;
created_at: Date;
}>>`
SELECT DISTINCT ON (record_id, COALESCE(event_id, ''))
record_id, event_id, form_name, status, issues, created_at
FROM iit_schema.qc_logs
WHERE project_id = ${projectId}
ORDER BY record_id, COALESCE(event_id, ''), created_at DESC
`;
const criticalIssues: ReportIssue[] = [];
const warningIssues: ReportIssue[] = [];
for (const log of latestQcLogs) {
// V2.1: 兼容两种 issues 格式
// 新格式: { items: [...], summary: {...} }
// 旧格式: [...]
const rawIssues = log.issues as any;
let issues: any[] = [];
if (Array.isArray(rawIssues)) {
// 旧格式:直接是数组
issues = rawIssues;
} else if (rawIssues && Array.isArray(rawIssues.items)) {
// 新格式:对象包含 items 数组
issues = rawIssues.items;
} else {
continue;
}
for (const issue of issues) {
// V2.1: 构建自包含的 LLM 友好消息
const llmMessage = this.buildSelfContainedMessage(issue);
const reportIssue: ReportIssue = {
recordId: log.record_id,
ruleId: issue.ruleId || issue.ruleName || 'unknown',
ruleName: issue.ruleName || issue.message || 'Unknown Rule',
severity: this.normalizeSeverity(issue.severity || issue.level),
message: llmMessage, // V2.1: 使用自包含消息
field: issue.field,
actualValue: issue.actualValue,
expectedValue: issue.expectedValue || this.extractExpectedFromMessage(issue.message),
evidence: issue.evidence,
detectedAt: log.created_at.toISOString(),
};
if (reportIssue.severity === 'critical') {
criticalIssues.push(reportIssue);
} else if (reportIssue.severity === 'warning') {
warningIssues.push(reportIssue);
}
}
}
// V3.1: 按 recordId + ruleId 去重,保留最新的问题(避免多事件重复)
const deduplicateCritical = this.deduplicateIssues(criticalIssues);
const deduplicateWarning = this.deduplicateIssues(warningIssues);
return { criticalIssues: deduplicateCritical, warningIssues: deduplicateWarning };
}
/**
* V3.1: 按 recordId + ruleId 去重问题
*
* 同一记录的同一规则只保留一条(最新的,因为数据已按时间倒序排列)
*/
private deduplicateIssues(issues: ReportIssue[]): ReportIssue[] {
const seen = new Map<string, ReportIssue>();
for (const issue of issues) {
const key = `${issue.recordId}:${issue.ruleId}`;
if (!seen.has(key)) {
seen.set(key, issue);
}
// 如果已存在,跳过(因为按时间倒序,第一个就是最新的)
}
return Array.from(seen.values());
}
/**
* 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';
}
/**
* 获取表单统计
*/
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' OR status = 'GREEN') as passed,
COUNT(*) FILTER (WHERE status = 'FAIL' OR status = 'RED') as failed
FROM (
SELECT DISTINCT ON (record_id, form_name)
record_id, form_name, status
FROM iit_schema.qc_logs
WHERE project_id = ${projectId} AND form_name IS NOT NULL
ORDER BY record_id, form_name, created_at DESC
) latest_logs
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) * 100 * 10) / 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[]
): string {
const now = new Date().toISOString();
// V2.1: 计算 Top Issues
const allIssues = [...criticalIssues, ...warningIssues];
const topIssues = this.calculateTopIssues(allIssues);
// V2.1: 按受试者分组
const groupedCritical = this.groupIssuesByRecord(criticalIssues);
const failedRecordCount = groupedCritical.length;
let xml = `<?xml version="1.0" encoding="UTF-8"?>
<qc_context project_id="${projectId}" project_name="${this.escapeXml(projectName)}" generated="${now}">
<!-- 1. 宏观统计 (Aggregate) -->
<summary>
- 状态: ${failedRecordCount}/${summary.totalRecords} 记录存在严重违规 (${summary.totalRecords > 0 ? Math.round((failedRecordCount / summary.totalRecords) * 100) : 0}% Fail)
- 通过率: ${summary.passRate}%
- 严重问题: ${summary.criticalIssues} | 警告: ${summary.warningIssues}
${topIssues.length > 0 ? ` - Top ${Math.min(3, topIssues.length)} 问题:
${topIssues.slice(0, 3).map((t, i) => ` ${i + 1}. ${t.ruleName} (${t.count}人)`).join('\n')}` : ''}
</summary>
`;
// V3.1: 严重问题详情(按受试者分组,显示所有问题)
if (groupedCritical.length > 0) {
xml += ` <!-- 2. 严重问题详情 (按受试者分组) -->\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`;
xml += ` **严重违规 (${group.issueCount}项)**:\n`;
// V3.1: 显示所有问题,不再限制
for (let i = 0; i < group.issues.length; i++) {
const issue = group.issues[i];
const llmLine = this.buildLlmIssueLine(issue, i + 1);
xml += ` ${llmLine}\n`;
}
xml += ` </record>\n\n`;
}
xml += ` </critical_issues>\n\n`;
} else {
xml += ` <critical_issues record_count="0" issue_count="0" />\n\n`;
}
// V3.1: 警告问题(显示所有)
const groupedWarning = this.groupIssuesByRecord(warningIssues);
if (groupedWarning.length > 0) {
xml += ` <!-- 3. 警告问题 -->\n`;
xml += ` <warning_issues record_count="${groupedWarning.length}" issue_count="${warningIssues.length}">\n`;
for (const group of groupedWarning) {
xml += ` <record id="${group.recordId}">\n`;
xml += ` **警告 (${group.issueCount}项)**:\n`;
for (let i = 0; i < group.issues.length; i++) {
const issue = group.issues[i];
const llmLine = this.buildLlmIssueLine(issue, i + 1);
xml += ` ${llmLine}\n`;
}
xml += ` </record>\n`;
}
xml += ` </warning_issues>\n\n`;
}
xml += `</qc_context>`;
return xml;
}
/**
* 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().toISOString();
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;
}
/**
* 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,
topIssues: report.topIssues, // V2.1
groupedIssues: report.groupedIssues, // V2.1
legacyXml: report.legacyXml, // V2.1
} as any,
llmReport: report.llmFriendlyXml,
generatedAt: new Date(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();