diff --git a/src/modules/ai-job/ai-job.module.ts b/src/modules/ai-job/ai-job.module.ts index 6bbfe41..b6deea6 100644 --- a/src/modules/ai-job/ai-job.module.ts +++ b/src/modules/ai-job/ai-job.module.ts @@ -39,6 +39,8 @@ import { QuizGenerationSnapshotBuilder } from './quiz-generation-snapshot-builde import { QuizGenerationExecutor } from './quiz-generation-executor'; import { QuizGenerationValidator } from './quiz-generation-validator'; import { QuizGenerationProjector } from './quiz-generation-projector'; +import { LearningAnalysisRegistrationService } from './learning-analysis-registration.service'; +import { LearningAnalysisSnapshotBuilder } from './learning-analysis-snapshot-builder'; import { FeynmanBusinessValidator, FeynmanReferenceValidator, @@ -86,6 +88,8 @@ import { AppConfigModule } from '../config/config.module'; QuizGenerationExecutor, QuizGenerationValidator, 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: AI_JOB_EXECUTION_ENGINE, useExisting: AiJobExecutionEngineImpl }, ], diff --git a/src/modules/ai-job/learning-analysis-job-definition.ts b/src/modules/ai-job/learning-analysis-job-definition.ts new file mode 100644 index 0000000..a0bef9d --- /dev/null +++ b/src/modules/ai-job/learning-analysis-job-definition.ts @@ -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, + }, +}; diff --git a/src/modules/ai-job/learning-analysis-registration.service.ts b/src/modules/ai-job/learning-analysis-registration.service.ts new file mode 100644 index 0000000..cfaafd1 --- /dev/null +++ b/src/modules/ai-job/learning-analysis-registration.service.ts @@ -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; } + } + } +} diff --git a/src/modules/ai-job/learning-analysis-snapshot-builder.spec.ts b/src/modules/ai-job/learning-analysis-snapshot-builder.spec.ts new file mode 100644 index 0000000..0c56d8a --- /dev/null +++ b/src/modules/ai-job/learning-analysis-snapshot-builder.spec.ts @@ -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 长度 16,hex 格式', 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}$/); + }); + }); +}); diff --git a/src/modules/ai-job/learning-analysis-snapshot-builder.ts b/src/modules/ai-job/learning-analysis-snapshot-builder.ts new file mode 100644 index 0000000..eab8fda --- /dev/null +++ b/src/modules/ai-job/learning-analysis-snapshot-builder.ts @@ -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; + }; + 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, + }; + } +}