api-server/src/modules/ai-job/learning-analysis-snapshot-builder.ts
wangdl d537ff3bd0
Some checks failed
Deploy API Server / build-and-unit (push) Failing after 29s
Deploy API Server / current-integration (push) Has been skipped
Deploy API Server / backward-compat (push) Has been skipped
Deploy API Server / deploy (push) Has been skipped
feat(M-AI-08-02): learning analysis snapshot builder + definition
- LearningAnalysisSnapshotBuilder: aggregates 6 data dimensions from trusted server sources
- Windows: behavior 7 days / scores 30 days (matches legacy)
- Data quality: availableSources/missingSources/insufficientDataReasons
- Definition: learning_analysis, ai-background, primary tier
- contentHash: stable via sorted keys + SHA-256

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 21:36:57 +08:00

322 lines
12 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.

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<string, number>;
};
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<LearningAnalysisSnapshot> {
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<string, number>,
};
} 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<string, boolean>) {
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,
};
}
}