import { Injectable, NotFoundException, Logger } from '@nestjs/common'; import { ReviewRepository } from './review.repository'; import { ReviewCardGenerationWorkflow } from '../ai/workflows/review-card-generation.workflow'; import { SubmitReviewDto } from './dto/submit-review.dto'; // Simplified Anki SM-2 algorithm constants const INTERVALS = [1, 1, 3, 7, 14, 30, 60, 120, 240]; // days const EASE_FACTOR_DEFAULT = 2.5; const EASE_FACTOR_MIN = 1.3; @Injectable() export class ReviewService { private readonly logger = new Logger(ReviewService.name); constructor( private readonly reviewRepository: ReviewRepository, private readonly cardGenerationWorkflow: ReviewCardGenerationWorkflow, ) {} async getDueCards(userId: string) { return this.reviewRepository.findDueCards(userId); } async submitReview(userId: string, id: string, dto: SubmitReviewDto) { const card = await this.reviewRepository.findById(id); if (!card) throw new NotFoundException(`Review card ${id} not found`); // Anki SM-2 algorithm const rating = dto.rating; let intervalDays = Number(card.intervalDays) || 1; let easeFactor = Number(card.easeFactor) || EASE_FACTOR_DEFAULT; let repetitionCount = Number(card.repetitionCount) || 0; let lapseCount = Number(card.lapseCount) || 0; let scheduleState = (card as any).scheduleState || 'new'; if (rating >= 3) { lapseCount = 0; if (scheduleState === 'new' || scheduleState === 'learning') { const idx = Math.min(repetitionCount + 1, INTERVALS.length - 1); intervalDays = INTERVALS[idx]; } else { intervalDays = Math.round(intervalDays * easeFactor); } repetitionCount++; easeFactor = Math.max(EASE_FACTOR_MIN, easeFactor + (0.1 - (5 - rating) * (0.08 + (5 - rating) * 0.02))); scheduleState = 'review'; } else { lapseCount++; repetitionCount = 0; intervalDays = 1; easeFactor = Math.max(EASE_FACTOR_MIN, easeFactor - 0.2); scheduleState = 'relearning'; } const nextReviewAt = new Date(Date.now() + intervalDays * 86400000); const log = await this.reviewRepository.insertLog({ userId, reviewCardId: id, rating, responseText: dto.responseText, }); await this.reviewRepository.updateCard(id, { status: 'active', nextReviewAt, intervalDays, easeFactor, repetitionCount, lapseCount, }); return { log, nextReviewAt, scheduleState, intervalDays }; } async generateCards(userId: string, input: { knowledgeItemTitle: string; knowledgeItemContent: string; cardCount?: number; }) { const result = await this.cardGenerationWorkflow.execute({ userId, knowledgeItemTitle: input.knowledgeItemTitle, knowledgeItemContent: input.knowledgeItemContent, cardCount: input.cardCount, }); const savedCards: any[] = []; for (const card of result.cards) { const saved = await this.reviewRepository.insertCard({ userId, frontText: card.frontText, backText: card.backText, difficulty: card.difficulty, status: 'active', intervalDays: 1, easeFactor: EASE_FACTOR_DEFAULT, repetitionCount: 0, lapseCount: 0, nextReviewAt: new Date(), }); savedCards.push(saved); } return { cards: savedCards, totalCount: result.totalCount }; } }