543 lines
19 KiB
TypeScript
543 lines
19 KiB
TypeScript
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
import { Repository, SelectQueryBuilder } from 'typeorm';
|
|
import { BaseService } from '@common/base.service';
|
|
import { ActivityEvent } from '@common/events';
|
|
import { PaginatedResponse } from '@common/pagination.dto';
|
|
import { ProjectResolverService } from '@features/projects/backend/project-resolver.service';
|
|
import { DailyLogEntryType, MasteryState } from '@life-platform/shared';
|
|
import type { LearningTimeWindow, LearningDueResponse, LearningPathStats as LearningPathStatsType, VerificationQuery } from '@life-platform/shared';
|
|
import { LearningPath } from './entities/learning-path.entity';
|
|
import { Lesson } from './entities/lesson.entity';
|
|
import { LessonProgress } from './entities/lesson-progress.entity';
|
|
import { ReviewLog } from './entities/review-log.entity';
|
|
import { CreateLearningPathDto } from './dto/create-learning-path.dto';
|
|
import { UpdateLearningPathDto } from './dto/update-learning-path.dto';
|
|
import { CreateLessonDto } from './dto/create-lesson.dto';
|
|
import { UpdateLessonDto } from './dto/update-lesson.dto';
|
|
import { RecordReviewDto } from './dto/record-review.dto';
|
|
import { QueryLearningPathsDto } from './dto/query-learning-paths.dto';
|
|
import { QueryLessonsDto } from './dto/query-lessons.dto';
|
|
|
|
@Injectable()
|
|
export class LearningService extends BaseService<LearningPath> {
|
|
constructor(
|
|
@InjectRepository(LearningPath)
|
|
private readonly pathRepo: Repository<LearningPath>,
|
|
@InjectRepository(Lesson)
|
|
private readonly lessonRepo: Repository<Lesson>,
|
|
@InjectRepository(LessonProgress)
|
|
private readonly progressRepo: Repository<LessonProgress>,
|
|
@InjectRepository(ReviewLog)
|
|
private readonly reviewLogRepo: Repository<ReviewLog>,
|
|
private readonly projectResolver: ProjectResolverService,
|
|
private readonly eventEmitter: EventEmitter2,
|
|
) {
|
|
super(pathRepo);
|
|
}
|
|
|
|
protected get entityName() {
|
|
return 'LearningPath';
|
|
}
|
|
|
|
protected applyRelations(qb: SelectQueryBuilder<LearningPath>): void {
|
|
qb.leftJoinAndSelect('entity.domain', 'domain');
|
|
}
|
|
|
|
protected applyFilters(
|
|
qb: SelectQueryBuilder<LearningPath>,
|
|
filters: Record<string, unknown>,
|
|
): void {
|
|
if (filters['domainId']) {
|
|
qb.andWhere('entity.domainId = :domainId', { domainId: filters['domainId'] });
|
|
}
|
|
if (filters['status']) {
|
|
qb.andWhere('entity.status = :status', { status: filters['status'] });
|
|
}
|
|
if (filters['difficulty']) {
|
|
qb.andWhere('entity.difficulty = :difficulty', { difficulty: filters['difficulty'] });
|
|
}
|
|
if (filters['search']) {
|
|
qb.andWhere('entity.title ILIKE :search', { search: `%${filters['search']}%` });
|
|
}
|
|
}
|
|
|
|
// --- Learning Paths ---
|
|
|
|
async findAllPaths(query: QueryLearningPathsDto & Record<string, unknown>): Promise<PaginatedResponse<LearningPath>> {
|
|
const { page = 1, limit = 20, sort = 'sortOrder', order = 'ASC', ...filters } = query;
|
|
|
|
const qb = this.pathRepo.createQueryBuilder('entity');
|
|
qb.leftJoinAndSelect('entity.domain', 'domain');
|
|
this.applyFilters(qb, filters);
|
|
qb.orderBy(`entity.${sort}`, order as 'ASC' | 'DESC');
|
|
qb.skip((page - 1) * limit).take(limit);
|
|
|
|
const [data, total] = await qb.getManyAndCount();
|
|
|
|
return {
|
|
data,
|
|
meta: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
|
};
|
|
}
|
|
|
|
async findOnePath(id: string): Promise<LearningPath & { lessons: Lesson[] }> {
|
|
const path = await this.pathRepo.findOne({
|
|
where: { id },
|
|
relations: ['domain', 'lessons', 'lessons.progress'],
|
|
});
|
|
if (!path) {
|
|
throw new NotFoundException(`LearningPath with id '${id}' not found`);
|
|
}
|
|
path.lessons.sort((a, b) => a.sortOrder - b.sortOrder);
|
|
return path as LearningPath & { lessons: Lesson[] };
|
|
}
|
|
|
|
async createPath(dto: CreateLearningPathDto): Promise<LearningPath> {
|
|
const domainId = dto.domainId ?? await this.projectResolver.resolveDomainId(dto.projectId);
|
|
const path = this.pathRepo.create({
|
|
...dto,
|
|
domainId,
|
|
status: 'active',
|
|
});
|
|
const saved = await this.pathRepo.save(path);
|
|
this.eventEmitter.emit('activity', new ActivityEvent(DailyLogEntryType.LearningPathCreated, `Created learning path: ${saved.title}`, { learningPathId: saved.id }));
|
|
return saved;
|
|
}
|
|
|
|
async updatePath(id: string, dto: UpdateLearningPathDto): Promise<LearningPath> {
|
|
const path = await this.findOne(id);
|
|
Object.assign(path, dto);
|
|
return this.pathRepo.save(path);
|
|
}
|
|
|
|
async removePath(id: string): Promise<void> {
|
|
const path = await this.findOne(id);
|
|
await this.pathRepo.remove(path);
|
|
}
|
|
|
|
// --- Lessons ---
|
|
|
|
async findLessons(query: QueryLessonsDto & Record<string, unknown>): Promise<PaginatedResponse<Lesson>> {
|
|
const { page = 1, limit = 20, sort = 'sortOrder', order = 'ASC', ...filters } = query;
|
|
|
|
const qb = this.lessonRepo.createQueryBuilder('lesson');
|
|
qb.leftJoinAndSelect('lesson.progress', 'progress');
|
|
|
|
if (filters['learningPathId']) {
|
|
qb.andWhere('lesson.learningPathId = :learningPathId', { learningPathId: filters['learningPathId'] });
|
|
}
|
|
if (filters['masteryState']) {
|
|
qb.andWhere('progress.masteryState = :masteryState', { masteryState: filters['masteryState'] });
|
|
}
|
|
|
|
qb.orderBy(`lesson.${sort}`, order as 'ASC' | 'DESC');
|
|
qb.skip((page - 1) * limit).take(limit);
|
|
|
|
const [data, total] = await qb.getManyAndCount();
|
|
|
|
return {
|
|
data,
|
|
meta: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
|
};
|
|
}
|
|
|
|
async findOneLesson(id: string): Promise<Lesson> {
|
|
const lesson = await this.lessonRepo.findOne({
|
|
where: { id },
|
|
relations: ['progress', 'learningPath'],
|
|
});
|
|
if (!lesson) {
|
|
throw new NotFoundException(`Lesson with id '${id}' not found`);
|
|
}
|
|
return lesson;
|
|
}
|
|
|
|
async createLesson(dto: CreateLessonDto): Promise<Lesson> {
|
|
const path = await this.findOne(dto.learningPathId);
|
|
if (!path) {
|
|
throw new NotFoundException(`LearningPath with id '${dto.learningPathId}' not found`);
|
|
}
|
|
|
|
const lesson = this.lessonRepo.create(dto);
|
|
const savedLesson = await this.lessonRepo.save(lesson);
|
|
|
|
const progress = this.progressRepo.create({
|
|
lessonId: savedLesson.id,
|
|
masteryState: MasteryState.New,
|
|
easeFactor: 2.5,
|
|
intervalDays: 0,
|
|
repetitionCount: 0,
|
|
});
|
|
await this.progressRepo.save(progress);
|
|
|
|
this.eventEmitter.emit('activity', new ActivityEvent(DailyLogEntryType.LessonCreated, `Created lesson: ${savedLesson.title}`, { lessonId: savedLesson.id, learningPathId: dto.learningPathId }));
|
|
return this.findOneLesson(savedLesson.id);
|
|
}
|
|
|
|
async updateLesson(id: string, dto: UpdateLessonDto): Promise<Lesson> {
|
|
const lesson = await this.findOneLesson(id);
|
|
Object.assign(lesson, dto);
|
|
await this.lessonRepo.save(lesson);
|
|
return this.findOneLesson(id);
|
|
}
|
|
|
|
async removeLesson(id: string): Promise<void> {
|
|
const lesson = await this.findOneLesson(id);
|
|
await this.lessonRepo.remove(lesson);
|
|
}
|
|
|
|
// --- Search ---
|
|
|
|
async searchLessons(query: string, pathId?: string, limit = 10): Promise<Lesson[]> {
|
|
const qb = this.lessonRepo.createQueryBuilder('lesson');
|
|
qb.leftJoinAndSelect('lesson.progress', 'progress');
|
|
qb.leftJoinAndSelect('lesson.learningPath', 'path');
|
|
|
|
qb.where(
|
|
'(lesson.title ILIKE :query OR EXISTS (SELECT 1 FROM unnest(lesson.key_concepts) AS concept WHERE concept ILIKE :query))',
|
|
{ query: `%${query}%` },
|
|
);
|
|
|
|
if (pathId) {
|
|
qb.andWhere('lesson.learningPathId = :pathId', { pathId });
|
|
}
|
|
|
|
qb.andWhere('path.status = :active', { active: 'active' });
|
|
qb.orderBy('lesson.title', 'ASC');
|
|
qb.take(limit);
|
|
|
|
return qb.getMany();
|
|
}
|
|
|
|
// --- Pipeline-aware queries ---
|
|
|
|
async findLessonsByPipeline(filters: {
|
|
curriculumSkillId?: string;
|
|
pipelineStage?: string;
|
|
limit?: number;
|
|
}): Promise<{ lessons: Lesson[] }> {
|
|
const qb = this.lessonRepo.createQueryBuilder('lesson');
|
|
qb.leftJoinAndSelect('lesson.progress', 'progress');
|
|
|
|
if (filters.curriculumSkillId) {
|
|
qb.andWhere('lesson.curriculumSkillId = :skillId', { skillId: filters.curriculumSkillId });
|
|
}
|
|
if (filters.pipelineStage) {
|
|
qb.andWhere('lesson.pipelineStage = :stage', { stage: filters.pipelineStage });
|
|
}
|
|
|
|
qb.orderBy('lesson.sortOrder', 'ASC');
|
|
qb.take(filters.limit ?? 50);
|
|
|
|
const lessons = await qb.getMany();
|
|
return { lessons };
|
|
}
|
|
|
|
async findLesson(id: string): Promise<Lesson | null> {
|
|
return this.lessonRepo.findOne({
|
|
where: { id },
|
|
relations: ['progress', 'learningPath'],
|
|
});
|
|
}
|
|
|
|
// --- Reviews (SM-2) ---
|
|
|
|
async recordReview(lessonId: string, dto: RecordReviewDto): Promise<LessonProgress> {
|
|
const progress = await this.progressRepo.findOne({ where: { lessonId } });
|
|
if (!progress) {
|
|
throw new NotFoundException(`LessonProgress for lesson '${lessonId}' not found`);
|
|
}
|
|
|
|
// Load the lesson to check for verification requirements
|
|
const lesson = await this.lessonRepo.findOne({ where: { id: lessonId } });
|
|
|
|
const { easeFactor, intervalDays, repetitionCount } = this.applySm2(
|
|
dto.qualityRating,
|
|
Number(progress.easeFactor),
|
|
progress.intervalDays,
|
|
progress.repetitionCount,
|
|
);
|
|
|
|
// Check verification if the lesson has a verificationQuery
|
|
let verificationPassed = true;
|
|
if (lesson?.verificationQuery) {
|
|
verificationPassed = await this.checkVerification(lesson.verificationQuery);
|
|
}
|
|
|
|
// If verification fails, cap quality at 2 (hard) — blocks mastery progression
|
|
const effectiveQuality = verificationPassed ? dto.qualityRating : Math.min(dto.qualityRating, 2);
|
|
|
|
// Recalculate if quality was capped
|
|
const sm2Result =
|
|
effectiveQuality !== dto.qualityRating
|
|
? this.applySm2(effectiveQuality, Number(progress.easeFactor), progress.intervalDays, progress.repetitionCount)
|
|
: { easeFactor, intervalDays, repetitionCount };
|
|
|
|
const now = new Date();
|
|
const nextReviewAt = new Date(now.getTime() + sm2Result.intervalDays * 24 * 60 * 60 * 1000);
|
|
|
|
progress.easeFactor = sm2Result.easeFactor;
|
|
progress.intervalDays = sm2Result.intervalDays;
|
|
progress.repetitionCount = sm2Result.repetitionCount;
|
|
progress.lastQualityRating = dto.qualityRating;
|
|
progress.lastReviewedAt = now;
|
|
progress.nextReviewAt = nextReviewAt;
|
|
progress.masteryState = this.computeMasteryState(sm2Result.repetitionCount, sm2Result.easeFactor);
|
|
|
|
await this.progressRepo.save(progress);
|
|
|
|
const reviewLog = this.reviewLogRepo.create({
|
|
lessonProgressId: progress.id,
|
|
lessonId,
|
|
qualityRating: dto.qualityRating,
|
|
responseTimeMs: dto.responseTimeMs ?? null,
|
|
easeFactor: sm2Result.easeFactor,
|
|
intervalDays: sm2Result.intervalDays,
|
|
notes: verificationPassed ? (dto.notes ?? null) : `[verification_failed] ${dto.notes ?? ''}`.trim(),
|
|
});
|
|
await this.reviewLogRepo.save(reviewLog);
|
|
|
|
this.eventEmitter.emit('activity', new ActivityEvent(DailyLogEntryType.LessonReviewed, `Reviewed lesson (quality: ${dto.qualityRating}, mastery: ${progress.masteryState})`, { lessonId, qualityRating: dto.qualityRating, masteryState: progress.masteryState }));
|
|
return progress;
|
|
}
|
|
|
|
private async checkVerification(query: VerificationQuery): Promise<boolean> {
|
|
const entityManager = this.lessonRepo.manager;
|
|
const timeframeCondition = this.getTimeframeCondition(query.timeframe, query.entity);
|
|
|
|
const filterParams: unknown[] = [];
|
|
let filterConditions = '';
|
|
|
|
if (query.filters) {
|
|
for (const [key, value] of Object.entries(query.filters)) {
|
|
filterParams.push(value);
|
|
filterConditions += ` AND ${key} = $${filterParams.length}`;
|
|
}
|
|
}
|
|
|
|
switch (query.condition) {
|
|
case 'count_gte':
|
|
case 'has_entries': {
|
|
const result = await entityManager.query(
|
|
`SELECT COUNT(*) as cnt FROM ${query.entity} WHERE 1=1 ${timeframeCondition} ${filterConditions}`,
|
|
filterParams,
|
|
);
|
|
return parseInt(result[0]?.cnt ?? '0', 10) >= query.value;
|
|
}
|
|
case 'latest_value_gte': {
|
|
const result = await entityManager.query(
|
|
`SELECT value FROM ${query.entity} WHERE 1=1 ${timeframeCondition} ${filterConditions} ORDER BY ${this.getDateColumn(query.entity)} DESC LIMIT 1`,
|
|
filterParams,
|
|
);
|
|
return result.length > 0 && parseFloat(result[0].value) >= query.value;
|
|
}
|
|
case 'streak_gte': {
|
|
const dateColumn = this.getDateColumn(query.entity);
|
|
const result = await entityManager.query(
|
|
`WITH daily AS (
|
|
SELECT DISTINCT DATE(${dateColumn}) as d
|
|
FROM ${query.entity}
|
|
WHERE 1=1 ${timeframeCondition} ${filterConditions}
|
|
ORDER BY d DESC
|
|
),
|
|
streak AS (
|
|
SELECT d, d - (ROW_NUMBER() OVER (ORDER BY d DESC))::int * INTERVAL '1 day' as grp
|
|
FROM daily
|
|
)
|
|
SELECT COUNT(*) as streak_len
|
|
FROM streak
|
|
WHERE grp = (SELECT grp FROM streak LIMIT 1)`,
|
|
filterParams,
|
|
);
|
|
return parseInt(result[0]?.streak_len ?? '0', 10) >= query.value;
|
|
}
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private getDateColumn(entity: string): string {
|
|
switch (entity) {
|
|
case 'food_entries': return 'eaten_at';
|
|
case 'exercise_sessions': return 'date';
|
|
case 'health_measurements': return 'date';
|
|
case 'habit_check_ins': return 'checked_at';
|
|
case 'consumable_logs': return 'consumed_at';
|
|
default: return 'created_at';
|
|
}
|
|
}
|
|
|
|
private getTimeframeCondition(timeframe: string | undefined, entity: string): string {
|
|
if (!timeframe || timeframe === 'all_time') return '';
|
|
const dateColumn = this.getDateColumn(entity);
|
|
switch (timeframe) {
|
|
case 'day': return `AND ${dateColumn} >= CURRENT_DATE`;
|
|
case 'week': return `AND ${dateColumn} >= CURRENT_DATE - INTERVAL '7 days'`;
|
|
case 'month': return `AND ${dateColumn} >= CURRENT_DATE - INTERVAL '30 days'`;
|
|
default: return '';
|
|
}
|
|
}
|
|
|
|
async getReviewHistory(lessonId: string): Promise<ReviewLog[]> {
|
|
return this.reviewLogRepo.find({
|
|
where: { lessonId },
|
|
order: { createdAt: 'DESC' },
|
|
});
|
|
}
|
|
|
|
private applySm2(
|
|
quality: number,
|
|
easeFactor: number,
|
|
intervalDays: number,
|
|
repetitionCount: number,
|
|
): { easeFactor: number; intervalDays: number; repetitionCount: number } {
|
|
let newEf = easeFactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
|
|
newEf = Math.max(1.3, newEf);
|
|
|
|
let newInterval: number;
|
|
let newRep: number;
|
|
|
|
if (quality >= 3) {
|
|
if (repetitionCount === 0) {
|
|
newInterval = 1;
|
|
} else if (repetitionCount === 1) {
|
|
newInterval = 6;
|
|
} else {
|
|
newInterval = Math.round(intervalDays * newEf);
|
|
}
|
|
newRep = repetitionCount + 1;
|
|
} else {
|
|
newRep = 0;
|
|
newInterval = 1;
|
|
}
|
|
|
|
return { easeFactor: newEf, intervalDays: newInterval, repetitionCount: newRep };
|
|
}
|
|
|
|
private computeMasteryState(repetitionCount: number, easeFactor: number): string {
|
|
if (repetitionCount >= 5 && easeFactor >= 2.0) return MasteryState.Mastered;
|
|
if (repetitionCount >= 2) return MasteryState.Reviewing;
|
|
if (repetitionCount >= 1) return MasteryState.Learning;
|
|
return MasteryState.New;
|
|
}
|
|
|
|
// --- Due / Window ---
|
|
|
|
async getDue(limit = 10): Promise<LearningDueResponse> {
|
|
const now = new Date();
|
|
|
|
const reviewsDueQuery = this.progressRepo
|
|
.createQueryBuilder('progress')
|
|
.leftJoinAndSelect('progress.lesson', 'lesson')
|
|
.leftJoinAndSelect('lesson.learningPath', 'path')
|
|
.where('progress.nextReviewAt <= :now', { now })
|
|
.andWhere('progress.masteryState != :mastered', { mastered: MasteryState.Mastered })
|
|
.andWhere('path.status = :active', { active: 'active' })
|
|
.orderBy('progress.nextReviewAt', 'ASC')
|
|
.take(limit);
|
|
|
|
const dueProgress = await reviewsDueQuery.getMany();
|
|
|
|
const reviewsDue = dueProgress.map((p) => ({
|
|
lesson: p.lesson,
|
|
progress: p,
|
|
path: { id: p.lesson.learningPath.id, title: p.lesson.learningPath.title },
|
|
}));
|
|
|
|
const totalDueCount = await this.progressRepo
|
|
.createQueryBuilder('progress')
|
|
.leftJoin('progress.lesson', 'lesson')
|
|
.leftJoin('lesson.learningPath', 'path')
|
|
.where('progress.nextReviewAt <= :now', { now })
|
|
.andWhere('progress.masteryState != :mastered', { mastered: MasteryState.Mastered })
|
|
.andWhere('path.status = :active', { active: 'active' })
|
|
.getCount();
|
|
|
|
const nextNewLesson = await this.progressRepo
|
|
.createQueryBuilder('progress')
|
|
.leftJoinAndSelect('progress.lesson', 'lesson')
|
|
.leftJoinAndSelect('lesson.learningPath', 'path')
|
|
.where('progress.masteryState = :new', { new: MasteryState.New })
|
|
.andWhere('path.status = :active', { active: 'active' })
|
|
.orderBy('path.sortOrder', 'ASC')
|
|
.addOrderBy('lesson.sortOrder', 'ASC')
|
|
.getOne();
|
|
|
|
const nextNew = nextNewLesson
|
|
? {
|
|
lesson: nextNewLesson.lesson,
|
|
path: { id: nextNewLesson.lesson.learningPath.id, title: nextNewLesson.lesson.learningPath.title },
|
|
}
|
|
: null;
|
|
|
|
const activePaths = await this.pathRepo.find({ where: { status: 'active' } });
|
|
const inLearningWindow = activePaths.some((p) =>
|
|
this.isInLearningWindow(p.preferredLearningTimes, now),
|
|
);
|
|
|
|
return {
|
|
reviewsDue,
|
|
nextNew,
|
|
totalDue: totalDueCount,
|
|
inLearningWindow,
|
|
};
|
|
}
|
|
|
|
isInLearningWindow(windows: LearningTimeWindow[] | null, now: Date): boolean {
|
|
if (!windows || windows.length === 0) return true;
|
|
|
|
const currentHour = now.getHours();
|
|
const currentDay = now.getDay();
|
|
|
|
return windows.some((w) => {
|
|
const dayMatch = !w.dayOfWeek || w.dayOfWeek.length === 0 || w.dayOfWeek.includes(currentDay);
|
|
const hourMatch = currentHour >= w.startHour && currentHour < w.endHour;
|
|
return dayMatch && hourMatch;
|
|
});
|
|
}
|
|
|
|
// --- Stats ---
|
|
|
|
async getPathStats(pathId: string): Promise<LearningPathStatsType> {
|
|
const path = await this.findOne(pathId);
|
|
|
|
const lessons = await this.lessonRepo.find({
|
|
where: { learningPathId: pathId },
|
|
relations: ['progress'],
|
|
});
|
|
|
|
const counts = { mastered: 0, learning: 0, reviewing: 0, new: 0 };
|
|
let nextReviewAt: Date | null = null;
|
|
|
|
for (const lesson of lessons) {
|
|
const state = (lesson.progress?.masteryState ?? MasteryState.New) as MasteryState;
|
|
counts[state]++;
|
|
|
|
if (lesson.progress?.nextReviewAt) {
|
|
const reviewDate = new Date(lesson.progress.nextReviewAt);
|
|
if (!nextReviewAt || reviewDate < nextReviewAt) {
|
|
nextReviewAt = reviewDate;
|
|
}
|
|
}
|
|
}
|
|
|
|
const totalLessons = lessons.length;
|
|
const completionPct = totalLessons > 0 ? Math.round((counts.mastered / totalLessons) * 100) : 0;
|
|
|
|
return {
|
|
pathId,
|
|
title: path.title,
|
|
totalLessons,
|
|
...counts,
|
|
completionPct,
|
|
nextReviewAt: nextReviewAt?.toISOString() ?? null,
|
|
};
|
|
}
|
|
|
|
async getAllPathStats(): Promise<LearningPathStatsType[]> {
|
|
const activePaths = await this.pathRepo.find({ where: { status: 'active' } });
|
|
return Promise.all(activePaths.map((p) => this.getPathStats(p.id)));
|
|
}
|
|
}
|