feat(M-AI-08-02): learning analysis snapshot builder + definition
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

- 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>
This commit is contained in:
wangdl 2026-06-22 21:36:57 +08:00
parent 5628e6db90
commit d537ff3bd0
5 changed files with 515 additions and 0 deletions

View File

@ -39,6 +39,8 @@ import { QuizGenerationSnapshotBuilder } from './quiz-generation-snapshot-builde
import { QuizGenerationExecutor } from './quiz-generation-executor'; import { QuizGenerationExecutor } from './quiz-generation-executor';
import { QuizGenerationValidator } from './quiz-generation-validator'; import { QuizGenerationValidator } from './quiz-generation-validator';
import { QuizGenerationProjector } from './quiz-generation-projector'; import { QuizGenerationProjector } from './quiz-generation-projector';
import { LearningAnalysisRegistrationService } from './learning-analysis-registration.service';
import { LearningAnalysisSnapshotBuilder } from './learning-analysis-snapshot-builder';
import { import {
FeynmanBusinessValidator, FeynmanBusinessValidator,
FeynmanReferenceValidator, FeynmanReferenceValidator,
@ -86,6 +88,8 @@ import { AppConfigModule } from '../config/config.module';
QuizGenerationExecutor, QuizGenerationExecutor,
QuizGenerationValidator, QuizGenerationValidator,
QuizGenerationProjector, QuizGenerationProjector,
LearningAnalysisRegistrationService,
LearningAnalysisSnapshotBuilder,
{ provide: RESULT_PROJECTORS, useFactory: (synthetic: SyntheticResultProjector, activeRecall: ActiveRecallProjector, feynman: FeynmanProjector, reviewCard: ReviewCardGenerationProjector, quiz: QuizGenerationProjector) => [synthetic, activeRecall, feynman, reviewCard, quiz], inject: [SyntheticResultProjector, ActiveRecallProjector, FeynmanProjector, ReviewCardGenerationProjector, QuizGenerationProjector] } as any, { provide: RESULT_PROJECTORS, useFactory: (synthetic: SyntheticResultProjector, activeRecall: ActiveRecallProjector, feynman: FeynmanProjector, reviewCard: ReviewCardGenerationProjector, quiz: QuizGenerationProjector) => [synthetic, activeRecall, feynman, reviewCard, quiz], inject: [SyntheticResultProjector, ActiveRecallProjector, FeynmanProjector, ReviewCardGenerationProjector, QuizGenerationProjector] } as any,
{ provide: AI_JOB_EXECUTION_ENGINE, useExisting: AiJobExecutionEngineImpl }, { provide: AI_JOB_EXECUTION_ENGINE, useExisting: AiJobExecutionEngineImpl },
], ],

View File

@ -0,0 +1,52 @@
import type { JobDefinition } from './job-definition.types';
export const LEARNING_ANALYSIS_JOB_DEFINITION: JobDefinition = {
jobType: 'learning_analysis',
metadata: {
label: 'Learning Analysis',
description: 'Analyze user learning behavior, review performance, quiz results, and knowledge progress. Generates comprehensive analysis with strengths, weaknesses, trends, risks, and actionable recommendations.',
domain: 'analysis',
version: '1.0.0',
},
queue: {
queueName: 'ai-background',
defaultPriority: 5,
},
execution: {
timeoutMs: 180_000,
maxRetries: 2,
retryBackoff: { type: 'exponential', delay: 2000 },
cancellable: true,
abortStrategy: 'fail',
},
input: { schemaVersion: 'learning-analysis-v1' },
output: { schemaVersion: 'learning-analysis-v1' },
prompt: {
promptKey: 'learning-analysis',
promptVersion: '1.0.0',
},
model: {
modelTier: 'primary',
modelProvider: 'deepseek',
modelName: 'deepseek-v4-pro',
maxTokens: 4096,
},
credential: {
allowedModes: ['platform_key'],
defaultMode: 'platform_key',
},
projectorKey: 'learning_analysis_projector',
security: {
contentSafetyCheck: true,
outputRedaction: false,
},
};

View File

@ -0,0 +1,20 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { JobDefinitionRegistry, DuplicateJobTypeError } from './job-definition-registry';
import { LEARNING_ANALYSIS_JOB_DEFINITION } from './learning-analysis-job-definition';
@Injectable()
export class LearningAnalysisRegistrationService implements OnModuleInit {
private readonly logger = new Logger(LearningAnalysisRegistrationService.name);
constructor(private readonly registry: JobDefinitionRegistry) {}
onModuleInit(): void {
try {
this.registry.register(LEARNING_ANALYSIS_JOB_DEFINITION);
this.logger.log(`Learning Analysis Definition registered: jobType=learning_analysis queue=ai-background`);
} catch (err: unknown) {
if (err instanceof DuplicateJobTypeError) {
this.logger.log('Learning Analysis Definition already registered (dual process)');
} else { throw err; }
}
}
}

View File

@ -0,0 +1,118 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ForbiddenException } from '@nestjs/common';
import { LearningAnalysisSnapshotBuilder } from './learning-analysis-snapshot-builder';
import { LEARNING_ANALYSIS_JOB_DEFINITION } from './learning-analysis-job-definition';
import { JobDefinitionRegistry } from './job-definition-registry';
import { PrismaService } from '../../infrastructure/database/prisma.service';
describe('LearningAnalysisSnapshotBuilder', () => {
let builder: LearningAnalysisSnapshotBuilder;
let prisma: any;
let registry: any;
beforeEach(async () => {
prisma = {
user: { findUnique: jest.fn().mockResolvedValue({ id: 'u-001' }) },
userLearningProfile: { findUnique: jest.fn().mockResolvedValue({ userId: 'u-001', learningGoal: 'mastery', qualityPreference: 'standard', dailyAvailableMinutes: 60 }) },
dailyLearningActivity: { findMany: jest.fn().mockResolvedValue([{ durationSeconds: 1800, activeRecallCount: 3, reviewCount: 5, readingSeconds: 600 }]) },
reviewCard: { count: jest.fn().mockResolvedValue(10) },
reviewLog: { count: jest.fn().mockResolvedValue(15), findMany: jest.fn().mockResolvedValue([{ rating: 4 }, { rating: 3 }, { rating: 5 }]) },
quiz: { count: jest.fn().mockResolvedValue(2) },
quizAttempt: { findMany: jest.fn().mockResolvedValue([{ correctCount: 3, totalQuestions: 5 }, { correctCount: 4, totalQuestions: 5 }]) },
aiAnalysisResult: { findMany: jest.fn().mockResolvedValue([{ masteryScore: 75, weaknesses: ['w1', 'w2'] }]) },
focusItem: { count: jest.fn().mockResolvedValue(2), findMany: jest.fn().mockResolvedValue([{ knowledgeItemId: 'ki-1' }]) },
knowledgeItem: { count: jest.fn().mockResolvedValue(20), findMany: jest.fn().mockResolvedValue([{ id: 'ki-1', title: 'Weak Item' }]) },
};
registry = { get: jest.fn().mockReturnValue(LEARNING_ANALYSIS_JOB_DEFINITION) };
const module: TestingModule = await Test.createTestingModule({
providers: [
LearningAnalysisSnapshotBuilder,
{ provide: PrismaService, useValue: prisma },
{ provide: JobDefinitionRegistry, useValue: registry },
],
}).compile();
builder = module.get(LearningAnalysisSnapshotBuilder);
jest.spyOn(require('@nestjs/common').Logger.prototype, 'log').mockImplementation(() => {});
});
describe('build', () => {
it('构建完整快照', async () => {
const snapshot = await builder.build({ userId: 'u-001', triggerType: 'manual', operationId: 'op-001' });
expect(snapshot.schemaVersion).toBe('learning-analysis-v1');
expect(snapshot.snapshot.userId).toBe('u-001');
expect(snapshot.snapshot.triggerType).toBe('manual');
expect(snapshot.snapshot.studyMetrics.activeDays).toBe(1);
expect(snapshot.snapshot.studyMetrics.totalStudyDuration).toBe(1800);
expect(snapshot.snapshot.reviewMetrics.reviewAccuracy).toBeGreaterThan(0);
expect(snapshot.snapshot.quizMetrics.quizCount).toBe(2);
expect(snapshot.snapshot.activeRecallMetrics.count).toBe(1);
expect(snapshot.snapshot.feynmanMetrics.focusItemCount).toBe(2);
expect(snapshot.snapshot.knowledgeProgress.weakItems).toHaveLength(1);
});
it('prompt/model 值来自 Definition单一事实来源', async () => {
registry.get.mockReturnValue({ ...LEARNING_ANALYSIS_JOB_DEFINITION, prompt: { promptKey: 'custom-la', promptVersion: '2.0' } });
const snapshot = await builder.build({ userId: 'u-001', triggerType: 'manual', operationId: 'op-001' });
expect(snapshot.snapshot.promptKey).toBe('custom-la');
expect(snapshot.snapshot.promptVersion).toBe('2.0');
});
it('User 不存在 → ForbiddenException', async () => {
prisma.user.findUnique.mockResolvedValue(null);
await expect(builder.build({ userId: 'u-missing', triggerType: 'manual', operationId: 'op-001' })).rejects.toThrow(ForbiddenException);
});
it('无数据时 dataQuality 标记 insufficient', async () => {
prisma.dailyLearningActivity.findMany.mockResolvedValue([]);
prisma.reviewLog.findMany.mockResolvedValue([]);
prisma.reviewLog.count.mockResolvedValue(0);
prisma.reviewCard.count.mockResolvedValue(0);
prisma.quizAttempt.findMany.mockResolvedValue([]);
prisma.quiz.count.mockResolvedValue(0);
prisma.aiAnalysisResult.findMany.mockResolvedValue([]);
prisma.focusItem.count.mockResolvedValue(0);
prisma.focusItem.findMany.mockResolvedValue([]);
prisma.knowledgeItem.count.mockResolvedValue(0);
prisma.knowledgeItem.findMany.mockResolvedValue([]);
const snapshot = await builder.build({ userId: 'u-001', triggerType: 'manual', operationId: 'op-001' });
expect(snapshot.snapshot.dataQuality.overall).toBe('insufficient');
expect(snapshot.snapshot.dataQuality.insufficientDataReasons).toContain('no_data_in_window');
});
it('Snapshot 不含敏感字段', async () => {
const snapshot = await builder.build({ userId: 'u-001', triggerType: 'manual', operationId: 'op-001' });
const serialized = JSON.stringify(snapshot);
expect(serialized).not.toContain('Authorization');
expect(serialized).not.toContain('JWT');
expect(serialized).not.toContain('apiKey');
expect(serialized).not.toContain('DATABASE_URL');
expect(serialized).not.toContain('password');
});
it('窗口范围正确', async () => {
const snapshot = await builder.build({ userId: 'u-001', triggerType: 'scheduled', operationId: 'sched-001' });
expect(snapshot.snapshot.windowStart).toBeTruthy();
expect(snapshot.snapshot.windowEnd).toBeTruthy();
expect(new Date(snapshot.snapshot.windowEnd).getTime()).toBeGreaterThan(new Date(snapshot.snapshot.windowStart).getTime());
});
});
describe('computeHash', () => {
it('相同输入 → 相同 hash', async () => {
const s1 = await builder.build({ userId: 'u-001', triggerType: 'manual', operationId: 'op-001' });
const s2 = await builder.build({ userId: 'u-001', triggerType: 'manual', operationId: 'op-001' });
expect(builder.computeHash(s1)).toBe(builder.computeHash(s2));
});
it('hash 长度 16hex 格式', async () => {
const s = await builder.build({ userId: 'u-001', triggerType: 'manual', operationId: 'op-001' });
const hash = builder.computeHash(s);
expect(hash).toHaveLength(16);
expect(hash).toMatch(/^[0-9a-f]{16}$/);
});
});
});

View File

@ -0,0 +1,321 @@
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,
};
}
}