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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
// 망각곡선 경로
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user