life-manager/codebase/features/learning/backend/learning.service.ts
Claude Code 44deba3852 feat(learning): Implement comprehensive learning path with income tracking integration
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-03-17 17:51:05 -07:00

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)));
}
}