feat: SM-2 + 페르소나 하이브리드 복습 알고리즘
- SM-2 Easiness Factor + 페르소나별 망각속도 보정 (senior 1.3x ~ crammer 0.6x) - 최초 학습(iter=0): hard=1d, medium=3d, easy=7d 차등 간격 - 2회차~: 반복할수록 간격 증가 (6 × EF^(iter-1) × pf) - hard → EF 하락 + 간격 리셋(1일), easy → EF 상승 + 간격 확대 - EF 는 SkillSnapshot.s0 에 저장 (태그별 개인 학습 용이도) - 프론트 3버튼에 SM-2 기반 동적 간격 미리보기 표시 - 기존 PersonaForgetService.schedule/updateS0 유지 (selfDifficulty 없는 경우 fallback) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,34 @@ export const PERSONA_LAMBDA: Record<Persona, number> = {
|
||||
crammer: 0.6,
|
||||
};
|
||||
|
||||
// ── SM-2 Hybrid ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const PERSONA_FACTOR: Record<Persona, number> = {
|
||||
senior: 1.3,
|
||||
mid: 1.0,
|
||||
junior: 0.8,
|
||||
crammer: 0.6,
|
||||
};
|
||||
|
||||
export const DEFAULT_EF = 2.5;
|
||||
export const MIN_EF = 1.3;
|
||||
|
||||
export type SelfDifficulty = 'hard' | 'medium' | 'easy';
|
||||
|
||||
export interface SM2Input {
|
||||
iteration: number;
|
||||
selfDifficulty: SelfDifficulty;
|
||||
currentEF: number;
|
||||
persona: Persona;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export interface SM2Output {
|
||||
intervalDays: number;
|
||||
newEF: number;
|
||||
scheduledAt: Date;
|
||||
}
|
||||
|
||||
export const INTENSITY_THRESHOLD: Record<ReviewIntensity, number> = {
|
||||
strict: 0.7,
|
||||
moderate: 0.5,
|
||||
@@ -183,6 +211,69 @@ export class PersonaForgetService {
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
* SM-2 + 페르소나 하이브리드 스케줄러.
|
||||
*
|
||||
* iteration=0 (최초 학습):
|
||||
* hard → 1일
|
||||
* medium → round(3 × pf)일
|
||||
* easy → round(7 × pf)일
|
||||
*
|
||||
* iteration=1 (첫 복습):
|
||||
* hard → 1일
|
||||
* else → round(6 × pf)일
|
||||
*
|
||||
* iteration≥2:
|
||||
* hard → 1일 (EF 하락 + 리셋)
|
||||
* else → round(6 × EF^(iteration-1) × pf)일
|
||||
*
|
||||
* EF 업데이트:
|
||||
* hard → max(MIN_EF, EF - 0.3)
|
||||
* easy → max(MIN_EF, EF + 0.15)
|
||||
* medium → 변화 없음
|
||||
*
|
||||
* 결과는 [1, MAX_INTERVAL_DAYS] 범위로 클램핑.
|
||||
*/
|
||||
sm2Schedule(input: SM2Input): SM2Output {
|
||||
const { iteration, selfDifficulty, currentEF, persona } = input;
|
||||
const now = input.now ?? new Date();
|
||||
const pf = PERSONA_FACTOR[persona] ?? 1.0;
|
||||
|
||||
// EF 업데이트
|
||||
let newEF = currentEF;
|
||||
if (selfDifficulty === 'hard') {
|
||||
newEF = Math.max(MIN_EF, currentEF - 0.3);
|
||||
} else if (selfDifficulty === 'easy') {
|
||||
newEF = Math.max(MIN_EF, currentEF + 0.15);
|
||||
}
|
||||
|
||||
// 간격 계산
|
||||
let intervalDays: number;
|
||||
if (selfDifficulty === 'hard') {
|
||||
intervalDays = 1;
|
||||
} else if (iteration <= 0) {
|
||||
// 최초 학습 — selfDifficulty 에 따라 차등
|
||||
if (selfDifficulty === 'medium') {
|
||||
intervalDays = Math.round(3 * pf);
|
||||
} else {
|
||||
// easy
|
||||
intervalDays = Math.round(7 * pf);
|
||||
}
|
||||
} else if (iteration === 1) {
|
||||
intervalDays = Math.round(6 * pf);
|
||||
} else {
|
||||
intervalDays = Math.round(6 * Math.pow(newEF, iteration - 1) * pf);
|
||||
}
|
||||
|
||||
intervalDays = Math.max(1, Math.min(MAX_INTERVAL_DAYS, intervalDays));
|
||||
|
||||
return {
|
||||
intervalDays,
|
||||
newEF,
|
||||
scheduledAt: addDays(now, intervalDays),
|
||||
};
|
||||
}
|
||||
|
||||
sigmoid(x: number): number {
|
||||
return 1 / (1 + Math.exp(-x));
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { StudyResult, ReviewStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PersonaForgetService } from '../forget/persona-forget.service';
|
||||
import { PersonaForgetService, DEFAULT_EF } from '../forget/persona-forget.service';
|
||||
|
||||
@Injectable()
|
||||
export class ReviewsService {
|
||||
@@ -89,58 +89,96 @@ export class ReviewsService {
|
||||
where: { id: reviewId },
|
||||
});
|
||||
|
||||
// b/c. Snapshot upsert inside tx
|
||||
let newS0: number;
|
||||
if (review.studyLog.tagId) {
|
||||
const existing = await tx.skillSnapshot.findUnique({
|
||||
where: {
|
||||
userId_tagId: { userId, tagId: review.studyLog.tagId },
|
||||
},
|
||||
});
|
||||
newS0 = this.forget.updateS0({
|
||||
previousS0: existing?.s0 ?? null,
|
||||
result,
|
||||
baseCorrectRate: review.studyLog.baseCorrectRate ?? undefined,
|
||||
});
|
||||
await tx.skillSnapshot.upsert({
|
||||
where: {
|
||||
userId_tagId: { userId, tagId: review.studyLog.tagId },
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
tagId: review.studyLog.tagId,
|
||||
s0: newS0,
|
||||
lastUpdatedAt: now,
|
||||
sampleCount: 1,
|
||||
},
|
||||
update: {
|
||||
s0: newS0,
|
||||
lastUpdatedAt: now,
|
||||
sampleCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
newS0 = this.forget.updateS0({
|
||||
previousS0: null,
|
||||
result,
|
||||
baseCorrectRate: review.studyLog.baseCorrectRate ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// d. Compute next schedule
|
||||
const SELF_DIFFICULTY_DAYS: Record<string, number> = {
|
||||
hard: 1,
|
||||
medium: 7,
|
||||
easy: 30,
|
||||
};
|
||||
|
||||
// b/c. Snapshot upsert + next schedule (SM-2 or legacy)
|
||||
let nextScheduledAt: Date;
|
||||
let nextPredictedP: number | null = null;
|
||||
|
||||
// e. Fetch the true latest iteration for this studyLog inside tx
|
||||
const lastDone = await tx.reviewSchedule.findFirst({
|
||||
where: { studyLogId: review.studyLogId, status: 'done' },
|
||||
orderBy: { iteration: 'desc' },
|
||||
});
|
||||
const nextIteration = (lastDone?.iteration ?? flipped.iteration) + 1;
|
||||
|
||||
if (selfDifficulty) {
|
||||
const days = SELF_DIFFICULTY_DAYS[selfDifficulty];
|
||||
nextScheduledAt = new Date(now.getTime() + days * 24 * 3_600_000);
|
||||
// ── SM-2 하이브리드 경로 ──────────────────────────────────────────────
|
||||
let currentEF = DEFAULT_EF;
|
||||
if (review.studyLog.tagId) {
|
||||
const snap = await tx.skillSnapshot.findUnique({
|
||||
where: { userId_tagId: { userId, tagId: review.studyLog.tagId } },
|
||||
});
|
||||
if (snap && snap.s0 >= 1.3 && snap.s0 <= 4.0) {
|
||||
currentEF = snap.s0;
|
||||
}
|
||||
}
|
||||
|
||||
const sm2 = this.forget.sm2Schedule({
|
||||
iteration: nextIteration,
|
||||
selfDifficulty,
|
||||
currentEF,
|
||||
persona: user.persona,
|
||||
now,
|
||||
});
|
||||
nextScheduledAt = sm2.scheduledAt;
|
||||
nextPredictedP = null;
|
||||
|
||||
// EF 업데이트: tagId 있는 경우 저장
|
||||
if (review.studyLog.tagId) {
|
||||
await tx.skillSnapshot.upsert({
|
||||
where: { userId_tagId: { userId, tagId: review.studyLog.tagId } },
|
||||
create: {
|
||||
userId,
|
||||
tagId: review.studyLog.tagId,
|
||||
s0: sm2.newEF,
|
||||
lastUpdatedAt: now,
|
||||
sampleCount: nextIteration,
|
||||
},
|
||||
update: {
|
||||
s0: sm2.newEF,
|
||||
lastUpdatedAt: now,
|
||||
sampleCount: nextIteration,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// ── 기존 망각곡선 경로 (selfDifficulty 없는 경우) ────────────────────
|
||||
let newS0: number;
|
||||
if (review.studyLog.tagId) {
|
||||
const existing = await tx.skillSnapshot.findUnique({
|
||||
where: {
|
||||
userId_tagId: { userId, tagId: review.studyLog.tagId },
|
||||
},
|
||||
});
|
||||
newS0 = this.forget.updateS0({
|
||||
previousS0: existing?.s0 ?? null,
|
||||
result,
|
||||
baseCorrectRate: review.studyLog.baseCorrectRate ?? undefined,
|
||||
});
|
||||
await tx.skillSnapshot.upsert({
|
||||
where: {
|
||||
userId_tagId: { userId, tagId: review.studyLog.tagId },
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
tagId: review.studyLog.tagId,
|
||||
s0: newS0,
|
||||
lastUpdatedAt: now,
|
||||
sampleCount: 1,
|
||||
},
|
||||
update: {
|
||||
s0: newS0,
|
||||
lastUpdatedAt: now,
|
||||
sampleCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
newS0 = this.forget.updateS0({
|
||||
previousS0: null,
|
||||
result,
|
||||
baseCorrectRate: review.studyLog.baseCorrectRate ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const schedule = this.forget.schedule({
|
||||
s0: newS0,
|
||||
persona: user.persona,
|
||||
@@ -152,13 +190,6 @@ export class ReviewsService {
|
||||
nextPredictedP = schedule.predictedP;
|
||||
}
|
||||
|
||||
// e. Fetch the true latest iteration for this studyLog inside tx
|
||||
const lastDone = await tx.reviewSchedule.findFirst({
|
||||
where: { studyLogId: review.studyLogId, status: 'done' },
|
||||
orderBy: { iteration: 'desc' },
|
||||
});
|
||||
const nextIteration = (lastDone?.iteration ?? flipped.iteration) + 1;
|
||||
|
||||
// f. Insert next review row
|
||||
const nextRow = await tx.reviewSchedule.create({
|
||||
data: {
|
||||
@@ -171,7 +202,7 @@ export class ReviewsService {
|
||||
},
|
||||
});
|
||||
|
||||
return { updated: flipped, nextReview: nextRow, s0: newS0 };
|
||||
return { updated: flipped, nextReview: nextRow };
|
||||
});
|
||||
|
||||
return txResult;
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
StudyResult,
|
||||
} from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { PersonaForgetService } from "../forget/persona-forget.service";
|
||||
import { PersonaForgetService, DEFAULT_EF } from "../forget/persona-forget.service";
|
||||
|
||||
export interface CreateStudyLogInput {
|
||||
subjectId: number;
|
||||
@@ -551,53 +551,86 @@ export class StudyLogsService {
|
||||
},
|
||||
});
|
||||
|
||||
let s0 = 0.3;
|
||||
if (input.tagId) {
|
||||
const existing = await tx.skillSnapshot.findUnique({
|
||||
where: { userId_tagId: { userId, tagId: input.tagId } },
|
||||
});
|
||||
const prevS0 = existing?.s0 ?? null;
|
||||
s0 = this.forget.updateS0({
|
||||
previousS0: prevS0,
|
||||
result: input.result,
|
||||
baseCorrectRate: input.baseCorrectRate ?? undefined,
|
||||
});
|
||||
await tx.skillSnapshot.upsert({
|
||||
where: { userId_tagId: { userId, tagId: input.tagId } },
|
||||
create: {
|
||||
userId,
|
||||
tagId: input.tagId,
|
||||
s0,
|
||||
lastUpdatedAt: now,
|
||||
sampleCount: 1,
|
||||
},
|
||||
update: {
|
||||
s0,
|
||||
lastUpdatedAt: now,
|
||||
sampleCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
s0 = this.forget.updateS0({
|
||||
previousS0: null,
|
||||
result: input.result,
|
||||
baseCorrectRate: input.baseCorrectRate ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const SELF_DIFFICULTY_DAYS: Record<string, number> = {
|
||||
hard: 1,
|
||||
medium: 7,
|
||||
easy: 30,
|
||||
};
|
||||
|
||||
let scheduledAt: Date;
|
||||
let predictedP: number | null = null;
|
||||
|
||||
if (input.selfDifficulty) {
|
||||
const days = SELF_DIFFICULTY_DAYS[input.selfDifficulty];
|
||||
scheduledAt = new Date(now.getTime() + days * 24 * 3_600_000);
|
||||
// ── SM-2 하이브리드 경로 ────────────────────────────────────────────────
|
||||
// tagId 있으면 SkillSnapshot.s0 에서 EF 로드 (EF 범위 1.3~4.0 이면 사용)
|
||||
let currentEF = DEFAULT_EF;
|
||||
if (input.tagId) {
|
||||
const snap = await tx.skillSnapshot.findUnique({
|
||||
where: { userId_tagId: { userId, tagId: input.tagId } },
|
||||
});
|
||||
if (snap && snap.s0 >= 1.3 && snap.s0 <= 4.0) {
|
||||
currentEF = snap.s0;
|
||||
}
|
||||
}
|
||||
|
||||
const sm2 = this.forget.sm2Schedule({
|
||||
iteration: 0,
|
||||
selfDifficulty: input.selfDifficulty,
|
||||
currentEF,
|
||||
persona: user.persona,
|
||||
now,
|
||||
});
|
||||
scheduledAt = sm2.scheduledAt;
|
||||
predictedP = null;
|
||||
|
||||
// EF 저장: tagId 있는 경우 SkillSnapshot.s0 에 EF 값으로 upsert
|
||||
if (input.tagId) {
|
||||
await tx.skillSnapshot.upsert({
|
||||
where: { userId_tagId: { userId, tagId: input.tagId } },
|
||||
create: {
|
||||
userId,
|
||||
tagId: input.tagId,
|
||||
s0: sm2.newEF,
|
||||
lastUpdatedAt: now,
|
||||
sampleCount: 1,
|
||||
},
|
||||
update: {
|
||||
s0: sm2.newEF,
|
||||
lastUpdatedAt: now,
|
||||
sampleCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// ── 기존 망각곡선 경로 (selfDifficulty 없는 경우) ────────────────────────
|
||||
let s0 = 0.3;
|
||||
if (input.tagId) {
|
||||
const existing = await tx.skillSnapshot.findUnique({
|
||||
where: { userId_tagId: { userId, tagId: input.tagId } },
|
||||
});
|
||||
const prevS0 = existing?.s0 ?? null;
|
||||
s0 = this.forget.updateS0({
|
||||
previousS0: prevS0,
|
||||
result: input.result,
|
||||
baseCorrectRate: input.baseCorrectRate ?? undefined,
|
||||
});
|
||||
await tx.skillSnapshot.upsert({
|
||||
where: { userId_tagId: { userId, tagId: input.tagId } },
|
||||
create: {
|
||||
userId,
|
||||
tagId: input.tagId,
|
||||
s0,
|
||||
lastUpdatedAt: now,
|
||||
sampleCount: 1,
|
||||
},
|
||||
update: {
|
||||
s0,
|
||||
lastUpdatedAt: now,
|
||||
sampleCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
s0 = this.forget.updateS0({
|
||||
previousS0: null,
|
||||
result: input.result,
|
||||
baseCorrectRate: input.baseCorrectRate ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const schedule = this.forget.schedule({
|
||||
s0,
|
||||
persona: user.persona,
|
||||
@@ -620,7 +653,7 @@ export class StudyLogsService {
|
||||
},
|
||||
});
|
||||
|
||||
return { studyLog: log, nextReview: reviewRow, s0 };
|
||||
return { studyLog: log, nextReview: reviewRow };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,12 @@ import type { SampleProblem } from '../data/types';
|
||||
|
||||
type SelfDifficulty = 'hard' | 'medium' | 'easy';
|
||||
|
||||
const SELF_DIFFICULTY_DAYS: Record<SelfDifficulty, number> = {
|
||||
// SM-2 기반 최초 학습(iteration=0) 간격 미리보기
|
||||
// 서버와 동일한 공식: persona_factor 는 클라이언트 미지이므로 pf=1.0 기준
|
||||
const INITIAL_INTERVAL_DAYS: Record<SelfDifficulty, number> = {
|
||||
hard: 1,
|
||||
medium: 7,
|
||||
easy: 30,
|
||||
medium: 3,
|
||||
easy: 7,
|
||||
};
|
||||
|
||||
const DIFFICULTY_SCORE: Record<SampleProblem['difficulty'], number> = {
|
||||
@@ -611,7 +613,7 @@ function EbookViewer({ id, title, grade, problems }: EbookViewerProps) {
|
||||
>
|
||||
<SelfEvalEmoji>🔴</SelfEvalEmoji>
|
||||
<SelfEvalLabel>어려웠어</SelfEvalLabel>
|
||||
<SelfEvalInterval>1일 뒤</SelfEvalInterval>
|
||||
<SelfEvalInterval>{INITIAL_INTERVAL_DAYS.hard}일 뒤</SelfEvalInterval>
|
||||
</SelfEvalButton>
|
||||
<SelfEvalButton
|
||||
type="button"
|
||||
@@ -621,7 +623,7 @@ function EbookViewer({ id, title, grade, problems }: EbookViewerProps) {
|
||||
>
|
||||
<SelfEvalEmoji>🟡</SelfEvalEmoji>
|
||||
<SelfEvalLabel>괜찮았어</SelfEvalLabel>
|
||||
<SelfEvalInterval>7일 뒤</SelfEvalInterval>
|
||||
<SelfEvalInterval>{INITIAL_INTERVAL_DAYS.medium}일 뒤</SelfEvalInterval>
|
||||
</SelfEvalButton>
|
||||
<SelfEvalButton
|
||||
type="button"
|
||||
@@ -631,7 +633,7 @@ function EbookViewer({ id, title, grade, problems }: EbookViewerProps) {
|
||||
>
|
||||
<SelfEvalEmoji>🟢</SelfEvalEmoji>
|
||||
<SelfEvalLabel>쉬웠어</SelfEvalLabel>
|
||||
<SelfEvalInterval>30일 뒤</SelfEvalInterval>
|
||||
<SelfEvalInterval>{INITIAL_INTERVAL_DAYS.easy}일 뒤</SelfEvalInterval>
|
||||
</SelfEvalButton>
|
||||
</SelfEvalButtons>
|
||||
</>
|
||||
|
||||
@@ -29,6 +29,24 @@ import { animations, theme } from '@/styles/theme';
|
||||
|
||||
type ReviewAction = 'hard' | 'medium' | 'easy' | 'skip';
|
||||
|
||||
// SM-2 간격 미리보기 (클라이언트 측 근사값, 서버가 정확한 값 계산)
|
||||
// persona 를 모르므로 기본 pf=1.0, EF=2.5 기준
|
||||
const DEFAULT_EF_PREVIEW = 2.5;
|
||||
const MAX_INTERVAL_PREVIEW = 60;
|
||||
|
||||
function previewIntervalDays(
|
||||
iteration: number,
|
||||
selfDifficulty: 'hard' | 'medium' | 'easy',
|
||||
): number {
|
||||
if (selfDifficulty === 'hard') return 1;
|
||||
if (iteration <= 0) {
|
||||
return selfDifficulty === 'medium' ? 3 : 7;
|
||||
}
|
||||
if (iteration === 1) return 6;
|
||||
const days = Math.round(6 * Math.pow(DEFAULT_EF_PREVIEW, iteration - 1));
|
||||
return Math.max(1, Math.min(MAX_INTERVAL_PREVIEW, days));
|
||||
}
|
||||
|
||||
const SELF_DIFFICULTY_RESULT: Record<'hard' | 'medium' | 'easy', StudyResult> = {
|
||||
hard: 'incorrect',
|
||||
medium: 'partial',
|
||||
@@ -695,7 +713,7 @@ export default function ReviewPage() {
|
||||
<ActionLabel>
|
||||
🔴 어려웠어
|
||||
</ActionLabel>
|
||||
<ActionHelper>1일 뒤 다시 볼게</ActionHelper>
|
||||
<ActionHelper>{previewIntervalDays(currentItem.iteration, 'hard')}일 뒤 다시 볼게</ActionHelper>
|
||||
</ActionCopy>
|
||||
<ActionShortcut>1</ActionShortcut>
|
||||
</ActionButton>
|
||||
@@ -710,7 +728,7 @@ export default function ReviewPage() {
|
||||
<ActionLabel>
|
||||
🟡 괜찮았어
|
||||
</ActionLabel>
|
||||
<ActionHelper>7일 뒤 다시 볼게</ActionHelper>
|
||||
<ActionHelper>{previewIntervalDays(currentItem.iteration, 'medium')}일 뒤 다시 볼게</ActionHelper>
|
||||
</ActionCopy>
|
||||
<ActionShortcut>2</ActionShortcut>
|
||||
</ActionButton>
|
||||
@@ -725,7 +743,7 @@ export default function ReviewPage() {
|
||||
<ActionLabel>
|
||||
🟢 쉬웠어
|
||||
</ActionLabel>
|
||||
<ActionHelper>30일 뒤 다시 볼게</ActionHelper>
|
||||
<ActionHelper>{previewIntervalDays(currentItem.iteration, 'easy')}일 뒤 다시 볼게</ActionHelper>
|
||||
</ActionCopy>
|
||||
<ActionShortcut>3</ActionShortcut>
|
||||
</ActionButton>
|
||||
|
||||
Reference in New Issue
Block a user