2026-05-24 14:11:58 +08:00
|
|
|
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
2026-05-17 00:39:46 +08:00
|
|
|
import { ReviewRepository } from './review.repository';
|
2026-05-18 10:07:57 +08:00
|
|
|
import { ReviewCardGenerationWorkflow } from '../ai/workflows/review-card-generation.workflow';
|
2026-05-09 18:25:04 +08:00
|
|
|
import { SubmitReviewDto } from './dto/submit-review.dto';
|
|
|
|
|
|
2026-05-24 14:11:58 +08:00
|
|
|
// 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;
|
|
|
|
|
|
2026-05-09 18:25:04 +08:00
|
|
|
@Injectable()
|
2026-05-17 00:39:46 +08:00
|
|
|
export class ReviewService {
|
2026-05-24 14:11:58 +08:00
|
|
|
private readonly logger = new Logger(ReviewService.name);
|
|
|
|
|
|
2026-05-18 10:07:57 +08:00
|
|
|
constructor(
|
|
|
|
|
private readonly reviewRepository: ReviewRepository,
|
|
|
|
|
private readonly cardGenerationWorkflow: ReviewCardGenerationWorkflow,
|
|
|
|
|
) {}
|
2026-05-09 18:25:04 +08:00
|
|
|
|
2026-05-17 00:39:46 +08:00
|
|
|
async getDueCards(userId: string) {
|
|
|
|
|
return this.reviewRepository.findDueCards(userId);
|
2026-05-09 18:25:04 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-17 00:39:46 +08:00
|
|
|
async submitReview(userId: string, id: string, dto: SubmitReviewDto) {
|
2026-05-09 18:25:04 +08:00
|
|
|
const card = await this.reviewRepository.findById(id);
|
|
|
|
|
if (!card) throw new NotFoundException(`Review card ${id} not found`);
|
2026-05-24 14:11:58 +08:00
|
|
|
|
|
|
|
|
// Anki SM-2 algorithm
|
2026-05-24 14:25:54 +08:00
|
|
|
const rating = Number(dto.rating);
|
2026-05-24 14:11:58 +08:00
|
|
|
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);
|
|
|
|
|
|
2026-05-09 18:25:04 +08:00
|
|
|
const log = await this.reviewRepository.insertLog({
|
2026-05-24 14:25:54 +08:00
|
|
|
userId, reviewCardId: id, rating: dto.rating, responseText: dto.responseText,
|
2026-05-17 00:39:46 +08:00
|
|
|
});
|
2026-05-24 14:11:58 +08:00
|
|
|
|
2026-05-17 00:39:46 +08:00
|
|
|
await this.reviewRepository.updateCard(id, {
|
2026-05-24 14:25:54 +08:00
|
|
|
status: 'active', nextReviewAt, intervalDays, repetitionCount, lapseCount,
|
2026-05-09 18:25:04 +08:00
|
|
|
});
|
2026-05-24 14:11:58 +08:00
|
|
|
|
|
|
|
|
return { log, nextReviewAt, scheduleState, intervalDays };
|
2026-05-09 18:25:04 +08:00
|
|
|
}
|
2026-05-18 10:07:57 +08:00
|
|
|
|
|
|
|
|
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,
|
2026-05-24 14:11:58 +08:00
|
|
|
easeFactor: EASE_FACTOR_DEFAULT,
|
2026-05-18 10:07:57 +08:00
|
|
|
repetitionCount: 0,
|
|
|
|
|
lapseCount: 0,
|
|
|
|
|
nextReviewAt: new Date(),
|
|
|
|
|
});
|
|
|
|
|
savedCards.push(saved);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { cards: savedCards, totalCount: result.totalCount };
|
|
|
|
|
}
|
2026-05-09 18:25:04 +08:00
|
|
|
}
|