feat: 알고리즘 개선 + 태그 활용 강화

알고리즘:
- SkillSnapshot 필드 분리: s0(기억강도 0~1) + easeFactor(EF 1.3~4.0)
- SM-2에 intensity 배율 반영 (strict×0.7, moderate×1.0, relaxed×1.4)
- 망각곡선 보정 통합: SM-2 간격 후 예상 P<30%면 안전 간격으로 축소
- SM-2에서도 s0(기억강도) 업데이트하여 두 모델이 항상 동기화

태그 활용:
- 복습 세션에 태그 필터 칩 (특정 단원만 집중 복습)
- 복습 완료 요약에 태그별 통계 (어려움/보통/쉬움 카운트)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
reloop
2026-04-17 17:38:42 +09:00
parent 85caf9b71d
commit eeb94db8e7
6 changed files with 238 additions and 30 deletions

View File

@@ -381,7 +381,10 @@ model SkillSnapshot {
tagId Int?
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull)
/// 기억 강도 (0~1). 망각곡선에서 사용.
s0 Float
/// SM-2 Ease Factor (1.3~4.0). null이면 SM-2 미사용 태그.
easeFactor Float?
lastUpdatedAt DateTime @default(now())
sampleCount Int @default(0)

View File

@@ -52,18 +52,32 @@ export const MIN_EF = 1.3;
export type SelfDifficulty = 'hard' | 'medium' | 'easy';
/** SM-2 간격에 곱하는 학습 강도 배율 */
export const INTENSITY_SM2_FACTOR: Record<ReviewIntensity, number> = {
strict: 0.7,
moderate: 1.0,
relaxed: 1.4,
};
export interface SM2Input {
iteration: number;
selfDifficulty: SelfDifficulty;
currentEF: number;
persona: Persona;
intensity?: ReviewIntensity;
/** 현재 기억 강도 (0~1). 망각곡선 보정에 사용 */
currentS0?: number;
/** 문제 난이도 (0~1). 망각곡선 보정에 사용 */
difficulty?: number;
now?: Date;
}
export interface SM2Output {
intervalDays: number;
newEF: number;
newS0: number;
scheduledAt: Date;
predictedP: number | null;
}
export const INTENSITY_THRESHOLD: Record<ReviewIntensity, number> = {
@@ -238,8 +252,9 @@ export class PersonaForgetService {
const { iteration, selfDifficulty, currentEF, persona } = input;
const now = input.now ?? new Date();
const pf = PERSONA_FACTOR[persona] ?? 1.0;
const intensityFactor = INTENSITY_SM2_FACTOR[input.intensity ?? 'moderate'];
// EF 업데이트
// ── EF 업데이트 ──
let newEF = currentEF;
if (selfDifficulty === 'hard') {
newEF = Math.max(MIN_EF, currentEF - 0.3);
@@ -247,22 +262,51 @@ export class PersonaForgetService {
newEF = Math.max(MIN_EF, currentEF + 0.15);
}
// 간격 계산
// ── S0 (기억 강도) 업데이트 ──
const prevS0 = input.currentS0 ?? DEFAULT_INITIAL_S0;
const resultForS0: StudyResult =
selfDifficulty === 'hard' ? 'incorrect' : selfDifficulty === 'easy' ? 'correct' : 'partial';
const newS0 = this.updateS0({ previousS0: prevS0, result: resultForS0 });
// ── SM-2 간격 계산 ──
let intervalDays: number;
if (selfDifficulty === 'hard') {
intervalDays = 1;
} else if (iteration <= 0) {
// 최초 학습 — selfDifficulty 에 따라 차등
if (selfDifficulty === 'medium') {
intervalDays = Math.round(3 * pf);
intervalDays = Math.round(3 * pf * intensityFactor);
} else {
// easy
intervalDays = Math.round(7 * pf);
intervalDays = Math.round(7 * pf * intensityFactor);
}
} else if (iteration === 1) {
intervalDays = Math.round(6 * pf);
intervalDays = Math.round(6 * pf * intensityFactor);
} else {
intervalDays = Math.round(6 * Math.pow(newEF, iteration - 1) * pf);
intervalDays = Math.round(6 * Math.pow(newEF, iteration - 1) * pf * intensityFactor);
}
// ── 망각곡선 보정 ──
// SM-2가 계산한 간격 뒤의 예상 기억 강도를 시뮬레이션.
// 예상 P가 너무 낮으면 간격을 줄여서 안전장치 역할.
const lambda = PERSONA_LAMBDA[persona];
const D = input.difficulty ?? 0.5;
let predictedP: number | null = null;
if (selfDifficulty !== 'hard' && intervalDays > 1) {
const futureS = newS0 * Math.exp(-lambda * intervalDays);
predictedP = this.sigmoid(this.k * (futureS - D));
// 예상 정답 확률이 30% 이하로 떨어지면 간격을 축소
if (predictedP < 0.3 && intervalDays > 2) {
// 정답 확률 50%가 되는 시점으로 축소
const target50 = D; // sigmoid(k * (s - D)) = 0.5 ⟹ s = D
if (target50 > 0 && target50 < newS0) {
const safeDays = Math.max(1, Math.floor(-(1 / lambda) * Math.log(target50 / newS0)));
intervalDays = Math.min(intervalDays, safeDays);
}
// 재계산된 P
const adjS = newS0 * Math.exp(-lambda * intervalDays);
predictedP = this.sigmoid(this.k * (adjS - D));
}
}
intervalDays = Math.max(1, Math.min(MAX_INTERVAL_DAYS, intervalDays));
@@ -270,7 +314,9 @@ export class PersonaForgetService {
return {
intervalDays,
newEF,
newS0,
scheduledAt: addDays(now, intervalDays),
predictedP,
};
}

View File

@@ -124,26 +124,29 @@ export class MeService {
for (const review of pendingReviews) {
const { tagId, difficulty, baseCorrectRate } = review.studyLog;
let snap: { s0: number; lastUpdatedAt: Date } | null = null;
let snap: { s0: number; easeFactor: number | null; lastUpdatedAt: Date } | null = null;
if (tagId !== null) {
snap = await this.prisma.skillSnapshot.findUnique({
where: { userId_tagId: { userId, tagId } },
select: { s0: true, lastUpdatedAt: true },
select: { s0: true, easeFactor: true, lastUpdatedAt: true },
});
}
// SM-2 경로: SkillSnapshot.s0 가 EF 범위(1.3 ~ 4.0)에 있는 경우
if (snap !== null && snap.s0 >= 1.3 && snap.s0 <= 4.0) {
// SM-2 경로: easeFactor가 있으면 SM-2
if (snap !== null && snap.easeFactor !== null && snap.easeFactor !== undefined) {
const sm2 = this.forget.sm2Schedule({
iteration: review.iteration,
selfDifficulty: 'medium',
currentEF: snap.s0,
currentEF: snap.easeFactor,
persona: newPersona,
intensity: newIntensity,
currentS0: snap.s0,
difficulty: baseCorrectRate != null ? 1 - baseCorrectRate : difficulty,
now: review.createdAt,
});
await this.prisma.reviewSchedule.update({
where: { id: review.id },
data: { scheduledAt: sm2.scheduledAt },
data: { scheduledAt: sm2.scheduledAt, predictedP: sm2.predictedP },
});
} else {
// 망각곡선 경로

View File

@@ -152,14 +152,16 @@ export class ReviewsService {
const nextIteration = (lastDone?.iteration ?? flipped.iteration) + 1;
if (selfDifficulty) {
// ── SM-2 하이브리드 경로 ──────────────────────────────────────────────
// ── SM-2 하이브리드 + 망각곡선 보정 ──────────────────────────────────
let currentEF = DEFAULT_EF;
let currentS0 = 0.3;
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;
if (snap) {
currentEF = snap.easeFactor ?? DEFAULT_EF;
currentS0 = snap.s0;
}
}
@@ -168,24 +170,28 @@ export class ReviewsService {
selfDifficulty,
currentEF,
persona: user.persona,
intensity: user.reviewIntensity,
currentS0,
difficulty: D,
now,
});
nextScheduledAt = sm2.scheduledAt;
nextPredictedP = null;
nextPredictedP = sm2.predictedP;
// 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,
s0: sm2.newS0,
easeFactor: sm2.newEF,
lastUpdatedAt: now,
sampleCount: nextIteration,
},
update: {
s0: sm2.newEF,
s0: sm2.newS0,
easeFactor: sm2.newEF,
lastUpdatedAt: now,
sampleCount: nextIteration,
},

View File

@@ -688,15 +688,16 @@ export class StudyLogsService {
let predictedP: number | null = null;
if (input.selfDifficulty) {
// ── SM-2 하이브리드 경로 ────────────────────────────────────────────────
// tagId 있으면 SkillSnapshot.s0 에서 EF 로드 (EF 범위 1.3~4.0 이면 사용)
// ── SM-2 하이브리드 + 망각곡선 보정 ─────────────────────────────────────
let currentEF = DEFAULT_EF;
let currentS0 = 0.3;
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;
if (snap) {
currentEF = snap.easeFactor ?? DEFAULT_EF;
currentS0 = snap.s0;
}
}
@@ -705,24 +706,29 @@ export class StudyLogsService {
selfDifficulty: input.selfDifficulty,
currentEF,
persona: user.persona,
intensity: user.reviewIntensity,
currentS0,
difficulty: D,
now,
});
scheduledAt = sm2.scheduledAt;
predictedP = null;
predictedP = sm2.predictedP;
// EF 저장: tagId 있는 경우 SkillSnapshot.s0 에 EF 값으로 upsert
// EF와 S0를 분리 저장
if (input.tagId) {
await tx.skillSnapshot.upsert({
where: { userId_tagId: { userId, tagId: input.tagId } },
create: {
userId,
tagId: input.tagId,
s0: sm2.newEF,
s0: sm2.newS0,
easeFactor: sm2.newEF,
lastUpdatedAt: now,
sampleCount: 1,
},
update: {
s0: sm2.newEF,
s0: sm2.newS0,
easeFactor: sm2.newEF,
lastUpdatedAt: now,
sampleCount: { increment: 1 },
},

View File

@@ -102,6 +102,7 @@ export default function ReviewPage() {
const [penColor, setPenColor] = useState<PenColor>('#ef4444');
const [clearSignal, setClearSignal] = useState(0);
const [tagPromptFor, setTagPromptFor] = useState<{ studyLogId: number; tagId: number | null } | null>(null);
const [tagFilter, setTagFilter] = useState<string | null>(null);
useEffect(() => {
if (typeof window === 'undefined') return;
@@ -170,7 +171,22 @@ export default function ReviewPage() {
void load();
}, [load]);
const currentItem = queue?.[0] ?? null;
const availableTags = useMemo(() => {
if (!queue) return [];
const tags = new Map<string, string>();
for (const item of queue) {
const tag = item.studyLog.tag;
if (tag) tags.set(String(tag.id), tag.name);
}
return Array.from(tags.entries()).map(([id, name]) => ({ id, name }));
}, [queue]);
const filteredQueue = useMemo(() => {
if (!queue || !tagFilter) return queue;
return queue.filter((item) => String(item.studyLog.tag?.id) === tagFilter);
}, [queue, tagFilter]);
const currentItem = filteredQueue?.[0] ?? null;
const currentDetail = currentItem ? detailCache[currentItem.studyLogId] ?? null : null;
const currentProblem = currentDetail?.problem ?? null;
const currentPassage = currentProblem?.passage ?? null;
@@ -513,6 +529,35 @@ export default function ReviewPage() {
</CompletionStat>
</CompletionStats>
{(() => {
const tagStats = new Map<string, { name: string; hard: number; medium: number; easy: number }>();
for (const entry of history) {
if (entry.action === 'skip') continue;
const tagName = entry.item.studyLog.tag?.name ?? '미분류';
const prev = tagStats.get(tagName) ?? { name: tagName, hard: 0, medium: 0, easy: 0 };
prev[entry.action as 'hard' | 'medium' | 'easy'] += 1;
tagStats.set(tagName, prev);
}
if (tagStats.size === 0) return null;
return (
<TagStatsWrap>
<TagStatsTitle> </TagStatsTitle>
<TagStatsList>
{Array.from(tagStats.values()).map((tag) => (
<TagStatRow key={tag.name}>
<TagStatName>{tag.name}</TagStatName>
<TagStatBadges>
{tag.hard > 0 && <TagStatBadge $tone="hard">{tag.hard}</TagStatBadge>}
{tag.medium > 0 && <TagStatBadge $tone="medium">{tag.medium}</TagStatBadge>}
{tag.easy > 0 && <TagStatBadge $tone="easy">{tag.easy}</TagStatBadge>}
</TagStatBadges>
</TagStatRow>
))}
</TagStatsList>
</TagStatsWrap>
);
})()}
<CompletionActions>
<Link href="/dashboard">
<Button as="span" $variant="white">
@@ -577,6 +622,28 @@ export default function ReviewPage() {
</HintChip>
))}
</HintStrip>
{availableTags.length > 1 && (
<TagFilterStrip>
<TagFilterChip
type="button"
$active={tagFilter === null}
onClick={() => setTagFilter(null)}
>
({queue?.length ?? 0})
</TagFilterChip>
{availableTags.map((tag) => (
<TagFilterChip
key={tag.id}
type="button"
$active={tagFilter === tag.id}
onClick={() => setTagFilter(tagFilter === tag.id ? null : tag.id)}
>
{tag.name} ({queue?.filter((i) => String(i.studyLog.tag?.id) === tag.id).length ?? 0})
</TagFilterChip>
))}
</TagFilterStrip>
)}
</TopPanel>
<StackStage>
@@ -1809,6 +1876,83 @@ const PromptImageWrap = styled.div`
background: #fff;
`;
const TagFilterStrip = styled.div`
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 8px;
`;
const TagFilterChip = styled.button<{ $active: boolean }>`
padding: 5px 12px;
border-radius: 99px;
border: 1px solid ${({ $active }) => ($active ? 'rgba(129,140,248,0.5)' : theme.color.borderSoftAlpha)};
background: ${({ $active }) => ($active ? 'rgba(79,70,229,0.15)' : 'rgba(255,255,255,0.03)')};
color: ${({ $active }) => ($active ? '#c7d2fe' : theme.color.textSub)};
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
&:hover { background: rgba(79,70,229,0.1); color: ${theme.color.textBright}; }
`;
const TagStatsWrap = styled.div`
margin-top: 16px;
padding: 14px;
border-radius: 14px;
background: rgba(255,255,255,0.03);
border: 1px solid ${theme.color.borderSoftAlpha};
`;
const TagStatsTitle = styled.div`
font-size: 12px;
font-weight: 700;
color: ${theme.color.textSub};
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 10px;
`;
const TagStatsList = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const TagStatRow = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
`;
const TagStatName = styled.span`
font-size: 13px;
font-weight: 600;
color: ${theme.color.textBright};
`;
const TagStatBadges = styled.div`
display: flex;
gap: 4px;
`;
const TagStatBadge = styled.span<{ $tone: 'hard' | 'medium' | 'easy' }>`
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
background: ${({ $tone }) =>
$tone === 'hard' ? 'rgba(239,68,68,0.15)' : $tone === 'medium' ? 'rgba(245,158,11,0.15)' : 'rgba(34,197,94,0.15)'};
color: ${({ $tone }) =>
$tone === 'hard' ? '#f87171' : $tone === 'medium' ? '#fbbf24' : '#4ade80'};
`;
const AnswerRevealBlock = styled.div`
display: flex;
align-items: center;