import { Injectable, Logger, ForbiddenException } from '@nestjs/common'; import * as crypto from 'crypto'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { JobDefinitionRegistry } from './job-definition-registry'; /** * M-AI-08-02: Learning Analysis Snapshot Builder * * 从可信服务端数据源聚合学习指标,构建最小化、可复现的快照。 * * 窗口:行为 7 天 / 成绩 30 天(与 Legacy 一致) * 数据源:DailyLearningActivity, LearningSession, QuizAttempt, * ReviewCard+ReviewLog, AiAnalysisResult, FocusItem, * KnowledgeItem, UserLearningProfile */ const SNAPSHOT_SCHEMA_VERSION = 'learning-analysis-v1'; const BEHAVIOR_WINDOW_DAYS = 7; const SCORE_WINDOW_DAYS = 30; export interface LearningAnalysisSnapshot { schemaVersion: string; snapshot: { userId: string; triggerType: 'manual' | 'scheduled'; operationId: string; windowStart: string; windowEnd: string; timezone: string; aggregationVersion: string; sourceCutoffAt: string; learningGoal?: string; qualityPreference?: string; dailyAvailableMinutes?: number; studyMetrics: { totalStudyDuration: number; activeDays: number; sessionCount: number; averageSessionDuration: number; completionRate: number; }; reviewMetrics: { reviewDueCount: number; reviewCompletedCount: number; reviewAccuracy: number; overdueCount: number; retentionTrend: number; }; quizMetrics: { quizCount: number; attemptCount: number; accuracy: number; accuracyByQuestionType: Record; }; activeRecallMetrics: { count: number; avgScore: number; weaknessCount: number; }; feynmanMetrics: { count: number; weaknessCount: number; focusItemCount: number; }; knowledgeProgress: { totalItems: number; completedItems: number; inProgressItems: number; weakItems: Array<{ id: string; title: string }>; }; dataQuality: { availableSources: string[]; missingSources: string[]; sampleSize: number; coverageStart?: string; coverageEnd?: string; insufficientDataReasons: string[]; }; promptKey: string; promptVersion: string; modelTier: string; inputSchemaVersion: string; outputSchemaVersion: string; createdAt: string; }; } export interface LearningAnalysisSnapshotInput { userId: string; triggerType: 'manual' | 'scheduled'; operationId: string; } @Injectable() export class LearningAnalysisSnapshotBuilder { private readonly logger = new Logger(LearningAnalysisSnapshotBuilder.name); constructor( private readonly prisma: PrismaService, private readonly registry: JobDefinitionRegistry, ) {} async build(input: LearningAnalysisSnapshotInput): Promise { const def = this.registry.get('learning_analysis'); const now = new Date(); const behaviorStart = new Date(now.getTime() - BEHAVIOR_WINDOW_DAYS * 86400000); const scoreStart = new Date(now.getTime() - SCORE_WINDOW_DAYS * 86400000); // 校验用户存在 const user = await this.prisma.user.findUnique({ where: { id: input.userId }, select: { id: true } }); if (!user) throw new ForbiddenException(`User ${input.userId} not found`); // 加载档案 const profile = await this.prisma.userLearningProfile.findUnique({ where: { userId: input.userId } }); // 聚合各维度数据 const studyMetrics = await this.aggregateStudyMetrics(input.userId, behaviorStart, now); const reviewMetrics = await this.aggregateReviewMetrics(input.userId, scoreStart, now); const quizMetrics = await this.aggregateQuizMetrics(input.userId, scoreStart, now); const activeRecallMetrics = await this.aggregateActiveRecallMetrics(input.userId, scoreStart, now); const feynmanMetrics = await this.aggregateFeynmanMetrics(input.userId, scoreStart, now); const knowledgeProgress = await this.aggregateKnowledgeProgress(input.userId); // 数据质量 const dataQuality = this.buildDataQuality({ study: studyMetrics.activeDays > 0, review: reviewMetrics.reviewCompletedCount > 0, quiz: quizMetrics.quizCount > 0, activeRecall: activeRecallMetrics.count > 0, feynman: feynmanMetrics.count > 0, knowledge: knowledgeProgress.totalItems > 0, }); const windowEnd = now.toISOString().replace(/\.\d{3}Z$/, 'Z'); const windowStartStr = behaviorStart.toISOString().replace(/\.\d{3}Z$/, 'Z'); const snapshot: LearningAnalysisSnapshot = { schemaVersion: SNAPSHOT_SCHEMA_VERSION, snapshot: { userId: input.userId, triggerType: input.triggerType, operationId: input.operationId, windowStart: windowStartStr, windowEnd, timezone: 'Asia/Shanghai', aggregationVersion: '1.0.0', sourceCutoffAt: windowEnd, learningGoal: profile?.learningGoal ?? undefined, qualityPreference: profile?.qualityPreference ?? undefined, dailyAvailableMinutes: profile?.dailyAvailableMinutes ?? undefined, studyMetrics, reviewMetrics, quizMetrics, activeRecallMetrics, feynmanMetrics, knowledgeProgress, dataQuality, promptKey: def.prompt.promptKey, promptVersion: def.prompt.promptVersion, modelTier: def.model.modelTier, inputSchemaVersion: SNAPSHOT_SCHEMA_VERSION, outputSchemaVersion: def.output.schemaVersion, createdAt: now.toISOString().replace(/\.\d{3}Z$/, 'Z'), }, }; this.logger.log( `Built learning analysis snapshot: userId=${input.userId} ` + `activeDays=${studyMetrics.activeDays} quizAccuracy=${quizMetrics.accuracy} ` + `dataQuality=${dataQuality.overall}`, ); return snapshot; } computeHash(snapshot: LearningAnalysisSnapshot): string { const serialized = JSON.stringify(snapshot.snapshot, Object.keys(snapshot.snapshot).sort()); return crypto.createHash('sha256').update(serialized).digest('hex').substring(0, 16); } // ── Aggregators ── private async aggregateStudyMetrics(userId: string, start: Date, end: Date) { try { const activities = await this.prisma.dailyLearningActivity.findMany({ where: { userId, date: { gte: start, lte: end } }, select: { durationSeconds: true, activeRecallCount: true, reviewCount: true, readingSeconds: true }, }); const totalDuration = activities.reduce((s, a) => s + (a.durationSeconds || 0), 0); return { totalStudyDuration: totalDuration, activeDays: activities.length, sessionCount: activities.reduce((s, a) => s + (a.activeRecallCount || 0) + (a.reviewCount || 0), 0), averageSessionDuration: activities.length > 0 ? Math.round(totalDuration / activities.length) : 0, completionRate: 0, // computed from MaterialReadingProgress if available }; } catch { return { totalStudyDuration: 0, activeDays: 0, sessionCount: 0, averageSessionDuration: 0, completionRate: 0 }; } } private async aggregateReviewMetrics(userId: string, start: Date, end: Date) { try { const [dueCount, completedCount, reviews] = await Promise.all([ this.prisma.reviewCard.count({ where: { userId, nextReviewAt: { lte: end }, deletedAt: null } }), this.prisma.reviewLog.count({ where: { userId, createdAt: { gte: start } } }), this.prisma.reviewLog.findMany({ where: { userId, createdAt: { gte: start } }, select: { rating: true }, take: 200, }), ]); const ratings = reviews.filter(r => typeof r.rating === 'number').map(r => r.rating as number); const accuracy = ratings.length > 0 ? ratings.filter(r => r >= 3).length / ratings.length : 0; return { reviewDueCount: dueCount, reviewCompletedCount: completedCount, reviewAccuracy: Math.round(accuracy * 100) / 100, overdueCount: 0, retentionTrend: 0, }; } catch { return { reviewDueCount: 0, reviewCompletedCount: 0, reviewAccuracy: 0, overdueCount: 0, retentionTrend: 0 }; } } private async aggregateQuizMetrics(userId: string, start: Date, end: Date) { try { const [quizCount, attempts] = await Promise.all([ this.prisma.quiz.count({ where: { userId } }), this.prisma.quizAttempt.findMany({ where: { userId, startedAt: { gte: start } }, select: { correctCount: true, totalQuestions: true }, take: 50, }), ]); const accuracy = attempts.length > 0 ? attempts.reduce((s, a) => s + (a.totalQuestions > 0 ? a.correctCount / a.totalQuestions : 0), 0) / attempts.length : 0; return { quizCount: quizCount, attemptCount: attempts.length, accuracy: Math.round(accuracy * 100) / 100, accuracyByQuestionType: {} as Record, }; } catch { return { quizCount: 0, attemptCount: 0, accuracy: 0, accuracyByQuestionType: {} }; } } private async aggregateActiveRecallMetrics(userId: string, start: Date, end: Date) { try { const results = await this.prisma.aiAnalysisResult.findMany({ where: { userId, createdAt: { gte: start } }, select: { masteryScore: true, weaknesses: true }, take: 50, }); const scores = results.filter(r => typeof r.masteryScore === 'number').map(r => r.masteryScore as number); const weaknessCount = results.reduce((s, r) => s + (Array.isArray(r.weaknesses) ? r.weaknesses.length : 0), 0); return { count: results.length, avgScore: scores.length > 0 ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) : 0, weaknessCount, }; } catch { return { count: 0, avgScore: 0, weaknessCount: 0 }; } } private async aggregateFeynmanMetrics(userId: string, start: Date, end: Date) { try { const [results, focusItems] = await Promise.all([ this.prisma.aiAnalysisResult.findMany({ where: { userId, createdAt: { gte: start } }, select: { id: true, weaknesses: true }, take: 50, }), this.prisma.focusItem.count({ where: { userId, source: 'ai-analysis', createdAt: { gte: start } } }), ]); const weaknessCount = results.reduce((s, r) => s + (Array.isArray(r.weaknesses) ? r.weaknesses.length : 0), 0); return { count: results.length, weaknessCount, focusItemCount: focusItems }; } catch { return { count: 0, weaknessCount: 0, focusItemCount: 0 }; } } private async aggregateKnowledgeProgress(userId: string) { try { const items = await this.prisma.knowledgeItem.findMany({ where: { userId, deletedAt: null, status: 'active' }, select: { id: true, title: true }, take: 200, }); // Weak items: those with active FocusItems const focusItemKis = await this.prisma.focusItem.findMany({ where: { userId, status: 'open', knowledgeItemId: { not: null } }, select: { knowledgeItemId: true }, take: 50, }); const weakKiIds = new Set(focusItemKis.map(f => f.knowledgeItemId).filter(Boolean)); return { totalItems: await this.prisma.knowledgeItem.count({ where: { userId, deletedAt: null, status: 'active' } }), completedItems: 0, inProgressItems: 0, weakItems: items.filter(i => weakKiIds.has(i.id)).map(i => ({ id: i.id, title: i.title })), }; } catch { return { totalItems: 0, completedItems: 0, inProgressItems: 0, weakItems: [] }; } } // ── Data Quality ── private buildDataQuality(sources: Record) { const available = Object.entries(sources).filter(([, v]) => v).map(([k]) => k); const missing = Object.entries(sources).filter(([, v]) => !v).map(([k]) => k); const reasons: string[] = []; if (available.length === 0) reasons.push('no_data_in_window'); else if (available.length < 3) reasons.push('limited_data'); return { availableSources: available, missingSources: missing, sampleSize: available.length, coverageStart: undefined, coverageEnd: undefined, insufficientDataReasons: reasons, overall: available.length === 0 ? 'insufficient' as const : available.length < 3 ? 'limited' as const : 'sufficient' as const, }; } }