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>
This commit is contained in:
parent
5628e6db90
commit
d537ff3bd0
@ -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 },
|
||||||
],
|
],
|
||||||
|
|||||||
52
src/modules/ai-job/learning-analysis-job-definition.ts
Normal file
52
src/modules/ai-job/learning-analysis-job-definition.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
20
src/modules/ai-job/learning-analysis-registration.service.ts
Normal file
20
src/modules/ai-job/learning-analysis-registration.service.ts
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/modules/ai-job/learning-analysis-snapshot-builder.spec.ts
Normal file
118
src/modules/ai-job/learning-analysis-snapshot-builder.spec.ts
Normal 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 长度 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}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
321
src/modules/ai-job/learning-analysis-snapshot-builder.ts
Normal file
321
src/modules/ai-job/learning-analysis-snapshot-builder.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user